a11y cursor movement (#13405)
This commit is contained in:
parent
ca5ab1b42a
commit
52d06b8213
@ -1 +1 @@
|
|||||||
f888186e50f20fdb49ceea0dae74b6443a22ddaa
|
9d5cd4b12ec7fc29ad2117bf7851844be861b74d
|
||||||
|
@ -387,10 +387,18 @@ class _TextFieldState extends State<TextField> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new GestureDetector(
|
return new Semantics(
|
||||||
|
onTap: () {
|
||||||
|
if (!_controller.selection.isValid)
|
||||||
|
_controller.selection = new TextSelection.collapsed(offset: _controller.text.length);
|
||||||
|
_requestKeyboard();
|
||||||
|
},
|
||||||
|
child: new GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: _requestKeyboard,
|
onTap: _requestKeyboard,
|
||||||
child: child,
|
child: child,
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -401,6 +401,26 @@ class TextPainter {
|
|||||||
return value & 0xF800 == 0xD800;
|
return value & 0xF800 == 0xD800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the closest offset after `offset` at which the inout cursor can be
|
||||||
|
/// positioned.
|
||||||
|
int getOffsetAfter(int offset) {
|
||||||
|
final int nextCodeUnit = _text.codeUnitAt(offset);
|
||||||
|
if (nextCodeUnit == null)
|
||||||
|
return null;
|
||||||
|
// TODO(goderbauer): doesn't handle flag emojis (https://github.com/flutter/flutter/issues/13404).
|
||||||
|
return _isUtf16Surrogate(nextCodeUnit) ? offset + 2 : offset + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the closest offset before `offset` at which the inout cursor can
|
||||||
|
/// be positioned.
|
||||||
|
int getOffsetBefore(int offset) {
|
||||||
|
final int prevCodeUnit = _text.codeUnitAt(offset - 1);
|
||||||
|
if (prevCodeUnit == null)
|
||||||
|
return null;
|
||||||
|
// TODO(goderbauer): doesn't handle flag emojis (https://github.com/flutter/flutter/issues/13404).
|
||||||
|
return _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1;
|
||||||
|
}
|
||||||
|
|
||||||
Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) {
|
Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) {
|
||||||
final int prevCodeUnit = _text.codeUnitAt(offset - 1);
|
final int prevCodeUnit = _text.codeUnitAt(offset - 1);
|
||||||
if (prevCodeUnit == null)
|
if (prevCodeUnit == null)
|
||||||
|
@ -845,6 +845,12 @@ class RenderCustomPaint extends RenderProxyBox {
|
|||||||
if (properties.onDecrease != null) {
|
if (properties.onDecrease != null) {
|
||||||
config.addAction(SemanticsAction.decrease, properties.onDecrease);
|
config.addAction(SemanticsAction.decrease, properties.onDecrease);
|
||||||
}
|
}
|
||||||
|
if (properties.onMoveCursorForwardByCharacter != null) {
|
||||||
|
config.addAction(SemanticsAction.moveCursorForwardByCharacter, properties.onMoveCursorForwardByCharacter);
|
||||||
|
}
|
||||||
|
if (properties.onMoveCursorBackwardByCharacter != null) {
|
||||||
|
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, properties.onMoveCursorBackwardByCharacter);
|
||||||
|
}
|
||||||
|
|
||||||
newChild.updateWith(
|
newChild.updateWith(
|
||||||
config: config,
|
config: config,
|
||||||
|
@ -316,6 +316,7 @@ class RenderEditable extends RenderBox {
|
|||||||
_selection = value;
|
_selection = value;
|
||||||
_selectionRects = null;
|
_selectionRects = null;
|
||||||
markNeedsPaint();
|
markNeedsPaint();
|
||||||
|
markNeedsSemanticsUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offset at which the text should be painted.
|
/// The offset at which the text should be painted.
|
||||||
@ -346,6 +347,30 @@ class RenderEditable extends RenderBox {
|
|||||||
..textDirection = textDirection
|
..textDirection = textDirection
|
||||||
..isFocused = hasFocus
|
..isFocused = hasFocus
|
||||||
..isTextField = true;
|
..isTextField = true;
|
||||||
|
|
||||||
|
if (_selection?.isValid == true) {
|
||||||
|
if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) {
|
||||||
|
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, () {
|
||||||
|
final int offset = _textPainter.getOffsetBefore(_selection.extentOffset);
|
||||||
|
if (offset == null)
|
||||||
|
return;
|
||||||
|
onSelectionChanged(
|
||||||
|
new TextSelection.collapsed(offset: offset), this, SelectionChangedCause.keyboard,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_textPainter.getOffsetAfter(_selection.extentOffset) != null) {
|
||||||
|
config.addAction(SemanticsAction.moveCursorForwardByCharacter, () {
|
||||||
|
final int offset = _textPainter.getOffsetAfter(_selection.extentOffset);
|
||||||
|
if (offset == null)
|
||||||
|
return;
|
||||||
|
onSelectionChanged(
|
||||||
|
new TextSelection.collapsed(offset: offset), this, SelectionChangedCause.keyboard,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -2830,6 +2830,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
|
|||||||
VoidCallback onScrollDown,
|
VoidCallback onScrollDown,
|
||||||
VoidCallback onIncrease,
|
VoidCallback onIncrease,
|
||||||
VoidCallback onDecrease,
|
VoidCallback onDecrease,
|
||||||
|
VoidCallback onMoveCursorForwardByCharacter,
|
||||||
|
VoidCallback onMoveCursorBackwardByCharacter,
|
||||||
}) : assert(container != null),
|
}) : assert(container != null),
|
||||||
_container = container,
|
_container = container,
|
||||||
_explicitChildNodes = explicitChildNodes,
|
_explicitChildNodes = explicitChildNodes,
|
||||||
@ -2850,6 +2852,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
|
|||||||
_onScrollDown = onScrollDown,
|
_onScrollDown = onScrollDown,
|
||||||
_onIncrease = onIncrease,
|
_onIncrease = onIncrease,
|
||||||
_onDecrease = onDecrease,
|
_onDecrease = onDecrease,
|
||||||
|
_onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter,
|
||||||
|
_onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter,
|
||||||
super(child);
|
super(child);
|
||||||
|
|
||||||
/// If 'container' is true, this [RenderObject] will introduce a new
|
/// If 'container' is true, this [RenderObject] will introduce a new
|
||||||
@ -3162,6 +3166,42 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
|
|||||||
markNeedsSemanticsUpdate();
|
markNeedsSemanticsUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The handler for [SemanticsAction.onMoveCursorForwardByCharacter].
|
||||||
|
///
|
||||||
|
/// This handler is invoked when the user wants to move the cursor in a
|
||||||
|
/// text field forward by one character.
|
||||||
|
///
|
||||||
|
/// TalkBack users can trigger this by pressing the volume up key while the
|
||||||
|
/// input focus is in a text field.
|
||||||
|
VoidCallback get onMoveCursorForwardByCharacter => _onMoveCursorForwardByCharacter;
|
||||||
|
VoidCallback _onMoveCursorForwardByCharacter;
|
||||||
|
set onMoveCursorForwardByCharacter(VoidCallback handler) {
|
||||||
|
if (_onMoveCursorForwardByCharacter == handler)
|
||||||
|
return;
|
||||||
|
final bool hadValue = _onMoveCursorForwardByCharacter != null;
|
||||||
|
_onMoveCursorForwardByCharacter = handler;
|
||||||
|
if ((handler != null) != hadValue)
|
||||||
|
markNeedsSemanticsUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The handler for [SemanticsAction.onMoveCursorBackwardByCharacter].
|
||||||
|
///
|
||||||
|
/// This handler is invoked when the user wants to move the cursor in a
|
||||||
|
/// text field backward by one character.
|
||||||
|
///
|
||||||
|
/// TalkBack users can trigger this by pressing the volume down key while the
|
||||||
|
/// input focus is in a text field.
|
||||||
|
VoidCallback get onMoveCursorBackwardByCharacter => _onMoveCursorBackwardByCharacter;
|
||||||
|
VoidCallback _onMoveCursorBackwardByCharacter;
|
||||||
|
set onMoveCursorBackwardByCharacter(VoidCallback handler) {
|
||||||
|
if (_onMoveCursorBackwardByCharacter == handler)
|
||||||
|
return;
|
||||||
|
final bool hadValue = _onMoveCursorBackwardByCharacter != null;
|
||||||
|
_onMoveCursorBackwardByCharacter = handler;
|
||||||
|
if ((handler != null) != hadValue)
|
||||||
|
markNeedsSemanticsUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||||
config.isSemanticBoundary = container;
|
config.isSemanticBoundary = container;
|
||||||
@ -3204,6 +3244,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
|
|||||||
config.addAction(SemanticsAction.increase, _performIncrease);
|
config.addAction(SemanticsAction.increase, _performIncrease);
|
||||||
if (onDecrease != null)
|
if (onDecrease != null)
|
||||||
config.addAction(SemanticsAction.decrease, _performDecrease);
|
config.addAction(SemanticsAction.decrease, _performDecrease);
|
||||||
|
if (onMoveCursorForwardByCharacter != null)
|
||||||
|
config.addAction(SemanticsAction.moveCursorForwardByCharacter, _performMoveCursorForwardByCharacter);
|
||||||
|
if (onMoveCursorBackwardByCharacter != null)
|
||||||
|
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, _performMoveCursorBackwardByCharacter);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _performTap() {
|
void _performTap() {
|
||||||
@ -3245,6 +3289,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
|
|||||||
if (onDecrease != null)
|
if (onDecrease != null)
|
||||||
onDecrease();
|
onDecrease();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _performMoveCursorForwardByCharacter() {
|
||||||
|
if (onMoveCursorForwardByCharacter != null)
|
||||||
|
onMoveCursorForwardByCharacter();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performMoveCursorBackwardByCharacter() {
|
||||||
|
if (onMoveCursorBackwardByCharacter != null)
|
||||||
|
onMoveCursorBackwardByCharacter();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Causes the semantics of all earlier render objects below the same semantic
|
/// Causes the semantics of all earlier render objects below the same semantic
|
||||||
|
@ -252,6 +252,8 @@ class SemanticsProperties extends DiagnosticableTree {
|
|||||||
this.onScrollDown,
|
this.onScrollDown,
|
||||||
this.onIncrease,
|
this.onIncrease,
|
||||||
this.onDecrease,
|
this.onDecrease,
|
||||||
|
this.onMoveCursorForwardByCharacter,
|
||||||
|
this.onMoveCursorBackwardByCharacter,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// If non-null, indicates that this subtree represents a checkbox
|
/// If non-null, indicates that this subtree represents a checkbox
|
||||||
@ -436,6 +438,24 @@ class SemanticsProperties extends DiagnosticableTree {
|
|||||||
/// volume down button.
|
/// volume down button.
|
||||||
final VoidCallback onDecrease;
|
final VoidCallback onDecrease;
|
||||||
|
|
||||||
|
/// The handler for [SemanticsAction.onMoveCursorForwardByCharacter].
|
||||||
|
///
|
||||||
|
/// This handler is invoked when the user wants to move the cursor in a
|
||||||
|
/// text field forward by one character.
|
||||||
|
///
|
||||||
|
/// TalkBack users can trigger this by pressing the volume up key while the
|
||||||
|
/// input focus is in a text field.
|
||||||
|
final VoidCallback onMoveCursorForwardByCharacter;
|
||||||
|
|
||||||
|
/// The handler for [SemanticsAction.onMoveCursorBackwardByCharacter].
|
||||||
|
///
|
||||||
|
/// This handler is invoked when the user wants to move the cursor in a
|
||||||
|
/// text field backward by one character.
|
||||||
|
///
|
||||||
|
/// TalkBack users can trigger this by pressing the volume down key while the
|
||||||
|
/// input focus is in a text field.
|
||||||
|
final VoidCallback onMoveCursorBackwardByCharacter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder description) {
|
void debugFillProperties(DiagnosticPropertiesBuilder description) {
|
||||||
super.debugFillProperties(description);
|
super.debugFillProperties(description);
|
||||||
|
@ -4726,6 +4726,8 @@ class Semantics extends SingleChildRenderObjectWidget {
|
|||||||
VoidCallback onScrollDown,
|
VoidCallback onScrollDown,
|
||||||
VoidCallback onIncrease,
|
VoidCallback onIncrease,
|
||||||
VoidCallback onDecrease,
|
VoidCallback onDecrease,
|
||||||
|
VoidCallback onMoveCursorForwardByCharacter,
|
||||||
|
VoidCallback onMoveCursorBackwardByCharacter,
|
||||||
}) : this.fromProperties(
|
}) : this.fromProperties(
|
||||||
key: key,
|
key: key,
|
||||||
child: child,
|
child: child,
|
||||||
@ -4749,6 +4751,8 @@ class Semantics extends SingleChildRenderObjectWidget {
|
|||||||
onScrollDown: onScrollDown,
|
onScrollDown: onScrollDown,
|
||||||
onIncrease: onIncrease,
|
onIncrease: onIncrease,
|
||||||
onDecrease: onDecrease,
|
onDecrease: onDecrease,
|
||||||
|
onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter,
|
||||||
|
onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -4814,6 +4818,8 @@ class Semantics extends SingleChildRenderObjectWidget {
|
|||||||
onScrollDown: properties.onScrollDown,
|
onScrollDown: properties.onScrollDown,
|
||||||
onIncrease: properties.onIncrease,
|
onIncrease: properties.onIncrease,
|
||||||
onDecrease: properties.onDecrease,
|
onDecrease: properties.onDecrease,
|
||||||
|
onMoveCursorForwardByCharacter: properties.onMoveCursorForwardByCharacter,
|
||||||
|
onMoveCursorBackwardByCharacter: properties.onMoveCursorBackwardByCharacter,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4849,7 +4855,9 @@ class Semantics extends SingleChildRenderObjectWidget {
|
|||||||
..onScrollUp = properties.onScrollUp
|
..onScrollUp = properties.onScrollUp
|
||||||
..onScrollDown = properties.onScrollDown
|
..onScrollDown = properties.onScrollDown
|
||||||
..onIncrease = properties.onIncrease
|
..onIncrease = properties.onIncrease
|
||||||
..onDecrease = properties.onDecrease;
|
..onDecrease = properties.onDecrease
|
||||||
|
..onMoveCursorForwardByCharacter = properties.onMoveCursorForwardByCharacter
|
||||||
|
..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -356,4 +356,122 @@ void main() {
|
|||||||
expect(textState.selectionOverlay.handlesAreVisible, isFalse);
|
expect(textState.selectionOverlay.handlesAreVisible, isFalse);
|
||||||
expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10));
|
expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async {
|
||||||
|
final SemanticsTester semantics = new SemanticsTester(tester);
|
||||||
|
|
||||||
|
controller.text = 'test';
|
||||||
|
|
||||||
|
await tester.pumpWidget(new MaterialApp(
|
||||||
|
home: new EditableText(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
style: textStyle,
|
||||||
|
cursorColor: cursorColor,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
));
|
||||||
|
|
||||||
|
controller.selection = new TextSelection.collapsed(offset: controller.text.length);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// At end, can only go backwards.
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
actions: <SemanticsAction>[
|
||||||
|
SemanticsAction.moveCursorBackwardByCharacter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
controller.selection = new TextSelection.collapsed(offset: controller.text.length - 2);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Somewhere in the middle, can go in both directions.
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
actions: <SemanticsAction>[
|
||||||
|
SemanticsAction.moveCursorBackwardByCharacter,
|
||||||
|
SemanticsAction.moveCursorForwardByCharacter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
controller.selection = const TextSelection.collapsed(offset: 0);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// At beginning, can only go forward.
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
actions: <SemanticsAction>[
|
||||||
|
SemanticsAction.moveCursorForwardByCharacter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('can move cursor with a11y means', (WidgetTester tester) async {
|
||||||
|
final SemanticsTester semantics = new SemanticsTester(tester);
|
||||||
|
|
||||||
|
controller.text = 'test';
|
||||||
|
controller.selection = new TextSelection.collapsed(offset: controller.text.length);
|
||||||
|
|
||||||
|
await tester.pumpWidget(new MaterialApp(
|
||||||
|
home: new EditableText(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
style: textStyle,
|
||||||
|
cursorColor: cursorColor,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
actions: <SemanticsAction>[
|
||||||
|
SemanticsAction.moveCursorBackwardByCharacter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
final RenderEditable render = tester.allRenderObjects.firstWhere((RenderObject o) => o.runtimeType == RenderEditable);
|
||||||
|
final int semanticsId = render.debugSemantics.id;
|
||||||
|
|
||||||
|
expect(controller.selection.baseOffset, 4);
|
||||||
|
expect(controller.selection.extentOffset, 4);
|
||||||
|
|
||||||
|
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(controller.selection.baseOffset, 3);
|
||||||
|
expect(controller.selection.extentOffset, 3);
|
||||||
|
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
actions: <SemanticsAction>[
|
||||||
|
SemanticsAction.moveCursorBackwardByCharacter,
|
||||||
|
SemanticsAction.moveCursorForwardByCharacter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(controller.selection.baseOffset, 0);
|
||||||
|
expect(controller.selection.extentOffset, 0);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(semantics, includesNodeWith(
|
||||||
|
value: 'test',
|
||||||
|
actions: <SemanticsAction>[
|
||||||
|
SemanticsAction.moveCursorForwardByCharacter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -388,6 +388,8 @@ void main() {
|
|||||||
onScrollDown: () => performedActions.add(SemanticsAction.scrollDown),
|
onScrollDown: () => performedActions.add(SemanticsAction.scrollDown),
|
||||||
onIncrease: () => performedActions.add(SemanticsAction.increase),
|
onIncrease: () => performedActions.add(SemanticsAction.increase),
|
||||||
onDecrease: () => performedActions.add(SemanticsAction.decrease),
|
onDecrease: () => performedActions.add(SemanticsAction.decrease),
|
||||||
|
onMoveCursorForwardByCharacter: () => performedActions.add(SemanticsAction.moveCursorForwardByCharacter),
|
||||||
|
onMoveCursorBackwardByCharacter: () => performedActions.add(SemanticsAction.moveCursorBackwardByCharacter),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user