mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #1187 from tekdel/main
feat: Support markdown to bold text
This commit is contained in:
commit
8d6e1cdaa1
@ -37,6 +37,7 @@ class BuiltInAttributeKey {
|
|||||||
static String checkbox = 'checkbox';
|
static String checkbox = 'checkbox';
|
||||||
static String code = 'code';
|
static String code = 'code';
|
||||||
static String number = 'number';
|
static String number = 'number';
|
||||||
|
static String defaultFormating = 'defaultFormating';
|
||||||
|
|
||||||
static List<String> partialStyleKeys = [
|
static List<String> partialStyleKeys = [
|
||||||
BuiltInAttributeKey.bold,
|
BuiltInAttributeKey.bold,
|
||||||
|
@ -0,0 +1,132 @@
|
|||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// convert **abc** to bold abc.
|
||||||
|
ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
|
||||||
|
final selectionService = editorState.service.selectionService;
|
||||||
|
final selection = selectionService.currentSelection.value;
|
||||||
|
final textNodes = selectionService.currentSelectedNodes.whereType<TextNode>();
|
||||||
|
if (selection == null || !selection.isSingle || textNodes.length != 1) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
final textNode = textNodes.first;
|
||||||
|
final text = textNode.toRawString().substring(0, selection.end.offset);
|
||||||
|
|
||||||
|
// make sure the last two characters are **.
|
||||||
|
if (text.length < 2 || text[selection.end.offset - 1] != '*') {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find all the index of `*`.
|
||||||
|
final asteriskIndexes = <int>[];
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] == '*') {
|
||||||
|
asteriskIndexes.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asteriskIndexes.length < 3) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure the second to last and third to last asterisks are connected.
|
||||||
|
final thirdToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 3];
|
||||||
|
final secondToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 2];
|
||||||
|
final lastAsterisIndex = asteriskIndexes[asteriskIndexes.length - 1];
|
||||||
|
if (secondToLastAsteriskIndex != thirdToLastAsteriskIndex + 1 ||
|
||||||
|
lastAsterisIndex == secondToLastAsteriskIndex + 1) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the last three asterisks.
|
||||||
|
// update the style of the text surround by `** **` to bold.
|
||||||
|
// and update the cursor position.
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..deleteText(textNode, lastAsterisIndex, 1)
|
||||||
|
..deleteText(textNode, thirdToLastAsteriskIndex, 2)
|
||||||
|
..formatText(
|
||||||
|
textNode,
|
||||||
|
thirdToLastAsteriskIndex,
|
||||||
|
selection.end.offset - thirdToLastAsteriskIndex - 3,
|
||||||
|
{
|
||||||
|
BuiltInAttributeKey.bold: true,
|
||||||
|
BuiltInAttributeKey.defaultFormating: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
..afterSelection = Selection.collapsed(
|
||||||
|
Position(
|
||||||
|
path: textNode.path,
|
||||||
|
offset: selection.end.offset - 3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..commit();
|
||||||
|
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
};
|
||||||
|
|
||||||
|
// convert __abc__ to bold abc.
|
||||||
|
ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
|
||||||
|
final selectionService = editorState.service.selectionService;
|
||||||
|
final selection = selectionService.currentSelection.value;
|
||||||
|
final textNodes = selectionService.currentSelectedNodes.whereType<TextNode>();
|
||||||
|
if (selection == null || !selection.isSingle || textNodes.length != 1) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
final textNode = textNodes.first;
|
||||||
|
final text = textNode.toRawString().substring(0, selection.end.offset);
|
||||||
|
|
||||||
|
// make sure the last two characters are __.
|
||||||
|
if (text.length < 2 || text[selection.end.offset - 1] != '_') {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find all the index of `_`.
|
||||||
|
final underscoreIndexes = <int>[];
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] == '_') {
|
||||||
|
underscoreIndexes.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (underscoreIndexes.length < 3) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure the second to last and third to last underscores are connected.
|
||||||
|
final thirdToLastUnderscoreIndex =
|
||||||
|
underscoreIndexes[underscoreIndexes.length - 3];
|
||||||
|
final secondToLastUnderscoreIndex =
|
||||||
|
underscoreIndexes[underscoreIndexes.length - 2];
|
||||||
|
final lastAsterisIndex = underscoreIndexes[underscoreIndexes.length - 1];
|
||||||
|
if (secondToLastUnderscoreIndex != thirdToLastUnderscoreIndex + 1 ||
|
||||||
|
lastAsterisIndex == secondToLastUnderscoreIndex + 1) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the last three underscores.
|
||||||
|
// update the style of the text surround by `__ __` to bold.
|
||||||
|
// and update the cursor position.
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..deleteText(textNode, lastAsterisIndex, 1)
|
||||||
|
..deleteText(textNode, thirdToLastUnderscoreIndex, 2)
|
||||||
|
..formatText(
|
||||||
|
textNode,
|
||||||
|
thirdToLastUnderscoreIndex,
|
||||||
|
selection.end.offset - thirdToLastUnderscoreIndex - 3,
|
||||||
|
{
|
||||||
|
BuiltInAttributeKey.bold: true,
|
||||||
|
BuiltInAttributeKey.defaultFormating: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
..afterSelection = Selection.collapsed(
|
||||||
|
Position(
|
||||||
|
path: textNode.path,
|
||||||
|
offset: selection.end.offset - 3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..commit();
|
||||||
|
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
};
|
@ -4,6 +4,7 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_ke
|
|||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart';
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart';
|
||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
|
||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
|
||||||
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart';
|
||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart';
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart';
|
||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
|
||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
|
import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
|
||||||
@ -252,6 +253,16 @@ List<ShortcutEvent> builtInShortcutEvents = [
|
|||||||
command: 'tab',
|
command: 'tab',
|
||||||
handler: tabHandler,
|
handler: tabHandler,
|
||||||
),
|
),
|
||||||
|
ShortcutEvent(
|
||||||
|
key: 'Double stars to bold',
|
||||||
|
command: 'shift+asterisk',
|
||||||
|
handler: doubleAsterisksToBold,
|
||||||
|
),
|
||||||
|
ShortcutEvent(
|
||||||
|
key: 'Double underscores to bold',
|
||||||
|
command: 'shift+underscore',
|
||||||
|
handler: doubleUnderscoresToBold,
|
||||||
|
),
|
||||||
ShortcutEvent(
|
ShortcutEvent(
|
||||||
key: 'Backquote to code',
|
key: 'Backquote to code',
|
||||||
command: 'backquote',
|
command: 'backquote',
|
||||||
|
@ -139,6 +139,12 @@ extension on LogicalKeyboardKey {
|
|||||||
if (this == LogicalKeyboardKey.keyZ) {
|
if (this == LogicalKeyboardKey.keyZ) {
|
||||||
return PhysicalKeyboardKey.keyZ;
|
return PhysicalKeyboardKey.keyZ;
|
||||||
}
|
}
|
||||||
|
if (this == LogicalKeyboardKey.asterisk) {
|
||||||
|
return PhysicalKeyboardKey.digit8;
|
||||||
|
}
|
||||||
|
if (this == LogicalKeyboardKey.underscore) {
|
||||||
|
return PhysicalKeyboardKey.minus;
|
||||||
|
}
|
||||||
if (this == LogicalKeyboardKey.tilde) {
|
if (this == LogicalKeyboardKey.tilde) {
|
||||||
return PhysicalKeyboardKey.backquote;
|
return PhysicalKeyboardKey.backquote;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,277 @@
|
|||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import '../../infra/test_editor.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
setUpAll(() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('markdown_syntax_to_styled_text_handler.dart', () {
|
||||||
|
group('convert double asterisks to bold', () {
|
||||||
|
Future<void> insertAsterisk(
|
||||||
|
EditorWidgetTester editor, {
|
||||||
|
int repeat = 1,
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < repeat; i++) {
|
||||||
|
await editor.pressLogicKey(
|
||||||
|
LogicalKeyboardKey.asterisk,
|
||||||
|
isShiftPressed: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('**AppFlowy** to bold AppFlowy', (tester) async {
|
||||||
|
const text = '**AppFlowy*';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertAsterisk(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, true);
|
||||||
|
expect(textNode.toRawString(), 'AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('App**Flowy** to bold AppFlowy', (tester) async {
|
||||||
|
const text = 'App**Flowy*';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertAsterisk(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 3,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, true);
|
||||||
|
expect(textNode.toRawString(), 'AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async {
|
||||||
|
const text = '***AppFlowy*';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertAsterisk(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 1,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, true);
|
||||||
|
expect(textNode.toRawString(), '*AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('**AppFlowy** application to bold AppFlowy only',
|
||||||
|
(tester) async {
|
||||||
|
const boldText = '**AppFlowy*';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
|
||||||
|
for (var i = 0; i < boldText.length; i++) {
|
||||||
|
await editor.insertText(textNode, boldText[i], i);
|
||||||
|
}
|
||||||
|
await insertAsterisk(editor);
|
||||||
|
final boldTextLength = boldText.replaceAll('*', '').length;
|
||||||
|
final appFlowyBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: boldTextLength,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(appFlowyBold, true);
|
||||||
|
expect(textNode.toRawString(), 'AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('**** nothing changes', (tester) async {
|
||||||
|
const text = '***';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertAsterisk(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, false);
|
||||||
|
expect(textNode.toRawString(), text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('convert double underscores to bold', () {
|
||||||
|
Future<void> insertUnderscore(
|
||||||
|
EditorWidgetTester editor, {
|
||||||
|
int repeat = 1,
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < repeat; i++) {
|
||||||
|
await editor.pressLogicKey(
|
||||||
|
LogicalKeyboardKey.underscore,
|
||||||
|
isShiftPressed: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('__AppFlowy__ to bold AppFlowy', (tester) async {
|
||||||
|
const text = '__AppFlowy_';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertUnderscore(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, true);
|
||||||
|
expect(textNode.toRawString(), 'AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('App__Flowy__ to bold AppFlowy', (tester) async {
|
||||||
|
const text = 'App__Flowy_';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertUnderscore(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 3,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, true);
|
||||||
|
expect(textNode.toRawString(), 'AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async {
|
||||||
|
const text = '___AppFlowy_';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertUnderscore(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 1,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, true);
|
||||||
|
expect(textNode.toRawString(), '_AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('__AppFlowy__ application to bold AppFlowy only',
|
||||||
|
(tester) async {
|
||||||
|
const boldText = '__AppFlowy_';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
|
||||||
|
for (var i = 0; i < boldText.length; i++) {
|
||||||
|
await editor.insertText(textNode, boldText[i], i);
|
||||||
|
}
|
||||||
|
await insertUnderscore(editor);
|
||||||
|
final boldTextLength = boldText.replaceAll('_', '').length;
|
||||||
|
final appFlowyBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: boldTextLength,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(appFlowyBold, true);
|
||||||
|
expect(textNode.toRawString(), 'AppFlowy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('____ nothing changes', (tester) async {
|
||||||
|
const text = '___';
|
||||||
|
final editor = tester.editor..insertTextNode('');
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(path: [0], startOffset: 0),
|
||||||
|
);
|
||||||
|
final textNode = editor.nodeAtPath([0]) as TextNode;
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
await editor.insertText(textNode, text[i], i);
|
||||||
|
}
|
||||||
|
await insertUnderscore(editor);
|
||||||
|
final allBold = textNode.allSatisfyBoldInSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: textNode.toRawString().length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBold, false);
|
||||||
|
expect(textNode.toRawString(), text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user