mirror of
https://github.com/flutter/samples.git
synced 2025-11-08 13:58:47 +00:00
[Simplistic_Editor] Use new context menu API (#1733)
This commit is contained in:
@@ -11,11 +11,32 @@ class BasicTextField extends StatefulWidget {
|
||||
required this.controller,
|
||||
required this.style,
|
||||
required this.focusNode,
|
||||
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final TextStyle style;
|
||||
final FocusNode focusNode;
|
||||
final BasicTextFieldContextMenuBuilder? contextMenuBuilder;
|
||||
|
||||
static Widget _defaultContextMenuBuilder(
|
||||
BuildContext context,
|
||||
ClipboardStatus clipboardStatus,
|
||||
VoidCallback? onCopy,
|
||||
VoidCallback? onCut,
|
||||
VoidCallback? onPaste,
|
||||
VoidCallback? onSelectAll,
|
||||
TextSelectionToolbarAnchors anchors,
|
||||
) {
|
||||
return AdaptiveTextSelectionToolbar.editable(
|
||||
clipboardStatus: clipboardStatus,
|
||||
onCopy: onCopy,
|
||||
onCut: onCut,
|
||||
onPaste: onPaste,
|
||||
onSelectAll: onSelectAll,
|
||||
anchors: anchors,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<BasicTextField> createState() => _BasicTextFieldState();
|
||||
@@ -86,21 +107,31 @@ class _BasicTextFieldState extends State<BasicTextField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (Theme.of(this.context).platform) {
|
||||
// ignore: todo
|
||||
// TODO(Renzo-Olivares): Remove use of deprecated members once
|
||||
// TextSelectionControls.buildToolbar has been deleted.
|
||||
// See https://github.com/flutter/flutter/pull/124611 and
|
||||
// https://github.com/flutter/flutter/pull/124262 for more details.
|
||||
case TargetPlatform.iOS:
|
||||
_textSelectionControls = cupertinoTextSelectionControls;
|
||||
// ignore: deprecated_member_use
|
||||
_textSelectionControls = cupertinoTextSelectionHandleControls;
|
||||
break;
|
||||
case TargetPlatform.macOS:
|
||||
_textSelectionControls = cupertinoDesktopTextSelectionControls;
|
||||
// ignore: deprecated_member_use
|
||||
_textSelectionControls = cupertinoDesktopTextSelectionHandleControls;
|
||||
break;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
_textSelectionControls = materialTextSelectionControls;
|
||||
// ignore: deprecated_member_use
|
||||
_textSelectionControls = materialTextSelectionHandleControls;
|
||||
break;
|
||||
case TargetPlatform.linux:
|
||||
_textSelectionControls = desktopTextSelectionControls;
|
||||
// ignore: deprecated_member_use
|
||||
_textSelectionControls = desktopTextSelectionHandleControls;
|
||||
break;
|
||||
case TargetPlatform.windows:
|
||||
_textSelectionControls = desktopTextSelectionControls;
|
||||
// ignore: deprecated_member_use
|
||||
_textSelectionControls = desktopTextSelectionHandleControls;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -109,8 +140,17 @@ class _BasicTextFieldState extends State<BasicTextField> {
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (dragStartDetails) => _onDragStart(dragStartDetails),
|
||||
onPanUpdate: (dragUpdateDetails) => _onDragUpdate(dragUpdateDetails),
|
||||
onSecondaryTapDown: (secondaryTapDownDetails) {
|
||||
_renderEditable.selectWordsInRange(
|
||||
from: secondaryTapDownDetails.globalPosition,
|
||||
cause: SelectionChangedCause.tap);
|
||||
_renderEditable.handleSecondaryTapDown(secondaryTapDownDetails);
|
||||
_textInputClient!.hideToolbar();
|
||||
_textInputClient!.showToolbar();
|
||||
},
|
||||
onTap: () {
|
||||
_textInputClient!.requestKeyboard();
|
||||
_textInputClient!.hideToolbar();
|
||||
},
|
||||
onTapDown: (tapDownDetails) {
|
||||
_renderEditable.handleTapDown(tapDownDetails);
|
||||
@@ -160,6 +200,7 @@ class _BasicTextFieldState extends State<BasicTextField> {
|
||||
selectionControls: _textSelectionControls,
|
||||
onSelectionChanged: _handleSelectionChanged,
|
||||
showSelectionHandles: _showSelectionHandles,
|
||||
contextMenuBuilder: widget.contextMenuBuilder,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,6 +15,18 @@ import 'replacements.dart';
|
||||
typedef SelectionChangedCallback = void Function(
|
||||
TextSelection selection, SelectionChangedCause? cause);
|
||||
|
||||
/// Signature for a widget builder that builds a context menu for the given
|
||||
/// editable field.
|
||||
typedef BasicTextFieldContextMenuBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ClipboardStatus clipboardStatus,
|
||||
VoidCallback? onCopy,
|
||||
VoidCallback? onCut,
|
||||
VoidCallback? onPaste,
|
||||
VoidCallback? onSelectAll,
|
||||
TextSelectionToolbarAnchors anchors,
|
||||
);
|
||||
|
||||
/// A basic text input client. An implementation of [DeltaTextInputClient] meant to
|
||||
/// send/receive information from the framework to the platform's text input plugin
|
||||
/// and vice-versa.
|
||||
@@ -25,6 +37,7 @@ class BasicTextInputClient extends StatefulWidget {
|
||||
required this.style,
|
||||
required this.focusNode,
|
||||
this.selectionControls,
|
||||
this.contextMenuBuilder,
|
||||
required this.onSelectionChanged,
|
||||
required this.showSelectionHandles,
|
||||
});
|
||||
@@ -35,6 +48,7 @@ class BasicTextInputClient extends StatefulWidget {
|
||||
final TextSelectionControls? selectionControls;
|
||||
final bool showSelectionHandles;
|
||||
final SelectionChangedCallback onSelectionChanged;
|
||||
final BasicTextFieldContextMenuBuilder? contextMenuBuilder;
|
||||
|
||||
@override
|
||||
State<BasicTextInputClient> createState() => BasicTextInputClientState();
|
||||
@@ -50,6 +64,7 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_clipboardStatus?.addListener(_onChangedClipboardStatus);
|
||||
widget.focusNode.addListener(_handleFocusChanged);
|
||||
widget.controller.addListener(_didChangeTextEditingValue);
|
||||
}
|
||||
@@ -63,18 +78,11 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_didChangeTextEditingValue);
|
||||
_clipboardStatus?.removeListener(_onChangedClipboardStatus);
|
||||
_clipboardStatus?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeInputControl(
|
||||
TextInputControl? oldControl, TextInputControl? newControl) {
|
||||
if (_hasFocus && _hasInputConnection) {
|
||||
oldControl?.hide();
|
||||
newControl?.show();
|
||||
}
|
||||
}
|
||||
|
||||
/// [DeltaTextInputClient] method implementations.
|
||||
@override
|
||||
void connectionClosed() {
|
||||
@@ -94,6 +102,15 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
@override
|
||||
TextEditingValue? get currentTextEditingValue => _value;
|
||||
|
||||
@override
|
||||
void didChangeInputControl(
|
||||
TextInputControl? oldControl, TextInputControl? newControl) {
|
||||
if (_hasFocus && _hasInputConnection) {
|
||||
oldControl?.hide();
|
||||
newControl?.show();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void insertTextPlaceholder(Size size) {
|
||||
// Will not implement. This method is used for Scribble support.
|
||||
@@ -296,27 +313,15 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
);
|
||||
}
|
||||
|
||||
void _userUpdateTextEditingValueWithDelta(
|
||||
TextEditingDelta textEditingDelta, SelectionChangedCause cause) {
|
||||
TextEditingValue value = _value;
|
||||
|
||||
value = textEditingDelta.apply(value);
|
||||
|
||||
if (widget.controller is ReplacementTextEditingController) {
|
||||
(widget.controller as ReplacementTextEditingController)
|
||||
.syncReplacementRanges(textEditingDelta);
|
||||
}
|
||||
|
||||
if (value != _value) {
|
||||
manager.updateTextEditingDeltaHistory([textEditingDelta]);
|
||||
}
|
||||
|
||||
userUpdateTextEditingValue(value, cause);
|
||||
void _onChangedClipboardStatus() {
|
||||
setState(() {
|
||||
// Inform the widget that the value of clipboardStatus has changed.
|
||||
});
|
||||
}
|
||||
|
||||
/// Keyboard text editing actions.
|
||||
// The Handling of the default text editing shortcuts with deltas
|
||||
// needs to be in the framework somehow. This should go through some kind of
|
||||
// needs to be in the framework somehow. This should go through some kind of
|
||||
// generic "replace" method like in EditableText.
|
||||
// EditableText converts intents like DeleteCharacterIntent to a generic
|
||||
// ReplaceTextIntent. I wonder if that could be done at a higher level, so
|
||||
@@ -448,6 +453,24 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
);
|
||||
}
|
||||
|
||||
void _userUpdateTextEditingValueWithDelta(
|
||||
TextEditingDelta textEditingDelta, SelectionChangedCause cause) {
|
||||
TextEditingValue value = _value;
|
||||
|
||||
value = textEditingDelta.apply(value);
|
||||
|
||||
if (widget.controller is ReplacementTextEditingController) {
|
||||
(widget.controller as ReplacementTextEditingController)
|
||||
.syncReplacementRanges(textEditingDelta);
|
||||
}
|
||||
|
||||
if (value != _value) {
|
||||
manager.updateTextEditingDeltaHistory([textEditingDelta]);
|
||||
}
|
||||
|
||||
userUpdateTextEditingValue(value, cause);
|
||||
}
|
||||
|
||||
/// For updates to text editing value.
|
||||
void _didChangeTextEditingValue() {
|
||||
_updateRemoteTextEditingValueIfNeeded();
|
||||
@@ -455,28 +478,6 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _toggleToolbar() {
|
||||
assert(_selectionOverlay != null);
|
||||
if (_selectionOverlay!.toolbarIsVisible) {
|
||||
hideToolbar(false);
|
||||
} else {
|
||||
showToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
// When the framework's text editing value changes we should update the text editing
|
||||
// value contained within the selection overlay or we might observe unexpected behavior.
|
||||
void _updateOrDisposeOfSelectionOverlayIfNeeded() {
|
||||
if (_selectionOverlay != null) {
|
||||
if (_hasFocus) {
|
||||
_selectionOverlay!.update(_value);
|
||||
} else {
|
||||
_selectionOverlay!.dispose();
|
||||
_selectionOverlay = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only update the platform's text input plugin's text editing value when it has changed
|
||||
// to avoid sending duplicate update messages to the engine.
|
||||
void _updateRemoteTextEditingValueIfNeeded() {
|
||||
@@ -488,6 +489,7 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
}
|
||||
}
|
||||
|
||||
/// For correctly positioning the candidate menu on macOS.
|
||||
// Sends the current composing rect to the iOS text input plugin via the text
|
||||
// input channel. We need to keep sending the information even if no text is
|
||||
// currently marked, as the information usually lags behind. The text input
|
||||
@@ -542,6 +544,20 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
// Not implemented.
|
||||
}
|
||||
|
||||
@override
|
||||
bool get cutEnabled => !textEditingValue.selection.isCollapsed;
|
||||
|
||||
@override
|
||||
bool get copyEnabled => !textEditingValue.selection.isCollapsed;
|
||||
|
||||
@override
|
||||
bool get pasteEnabled =>
|
||||
_clipboardStatus == null ||
|
||||
_clipboardStatus!.value == ClipboardStatus.pasteable;
|
||||
|
||||
@override
|
||||
bool get selectAllEnabled => textEditingValue.text.isNotEmpty;
|
||||
|
||||
@override
|
||||
void copySelection(SelectionChangedCause cause) {
|
||||
final TextSelection copyRange = textEditingValue.selection;
|
||||
@@ -599,17 +615,6 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
_clipboardStatus?.update();
|
||||
}
|
||||
|
||||
@override
|
||||
void hideToolbar([bool hideHandles = true]) {
|
||||
if (hideHandles) {
|
||||
// Hide the handles and the toolbar.
|
||||
_selectionOverlay?.hide();
|
||||
} else if (_selectionOverlay?.toolbarIsVisible ?? false) {
|
||||
// Hide only the toolbar but not the handles.
|
||||
_selectionOverlay?.hideToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pasteText(SelectionChangedCause cause) async {
|
||||
final TextSelection pasteRange = textEditingValue.selection;
|
||||
@@ -649,6 +654,18 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
),
|
||||
cause,
|
||||
);
|
||||
if (cause == SelectionChangedCause.toolbar) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.fuchsia:
|
||||
break;
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
hideToolbar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -699,6 +716,17 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void hideToolbar([bool hideHandles = true]) {
|
||||
if (hideHandles) {
|
||||
// Hide the handles and the toolbar.
|
||||
_selectionOverlay?.hide();
|
||||
} else if (_selectionOverlay?.toolbarIsVisible ?? false) {
|
||||
// Hide only the toolbar but not the handles.
|
||||
_selectionOverlay?.hideToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
/// For TextSelection.
|
||||
final LayerLink _startHandleLayerLink = LayerLink();
|
||||
final LayerLink _endHandleLayerLink = LayerLink();
|
||||
@@ -711,14 +739,14 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
void _handleSelectionChanged(
|
||||
TextSelection selection, SelectionChangedCause? cause) {
|
||||
// We return early if the selection is not valid. This can happen when the
|
||||
// text of [EditableText] is updated at the same time as the selection is
|
||||
// text of the editable is updated at the same time as the selection is
|
||||
// changed by a gesture event.
|
||||
if (!widget.controller.isSelectionWithinTextBounds(selection)) return;
|
||||
|
||||
widget.controller.selection = selection;
|
||||
|
||||
// This will show the keyboard for all selection changes on the
|
||||
// EditableText except for those triggered by a keyboard input.
|
||||
// editable except for those triggered by a keyboard input.
|
||||
// Typically BasicTextInputClient shouldn't take user keyboard input if
|
||||
// it's not focused already.
|
||||
switch (cause) {
|
||||
@@ -738,28 +766,12 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (widget.selectionControls == null) {
|
||||
if (widget.selectionControls == null && widget.contextMenuBuilder == null) {
|
||||
_selectionOverlay?.dispose();
|
||||
_selectionOverlay = null;
|
||||
} else {
|
||||
if (_selectionOverlay == null) {
|
||||
_selectionOverlay = TextSelectionOverlay(
|
||||
clipboardStatus: _clipboardStatus,
|
||||
context: context,
|
||||
value: _value,
|
||||
debugRequiredFor: widget,
|
||||
toolbarLayerLink: _toolbarLayerLink,
|
||||
startHandleLayerLink: _startHandleLayerLink,
|
||||
endHandleLayerLink: _endHandleLayerLink,
|
||||
renderObject: renderEditable,
|
||||
selectionControls: widget.selectionControls,
|
||||
selectionDelegate: this,
|
||||
dragStartBehavior: DragStartBehavior.start,
|
||||
onSelectionHandleTapped: () {
|
||||
_toggleToolbar();
|
||||
},
|
||||
magnifierConfiguration: TextMagnifierConfiguration.disabled,
|
||||
);
|
||||
_selectionOverlay = _createSelectionOverlay();
|
||||
} else {
|
||||
_selectionOverlay!.update(_value);
|
||||
}
|
||||
@@ -780,6 +792,136 @@ class BasicTextInputClientState extends State<BasicTextInputClient>
|
||||
}
|
||||
}
|
||||
|
||||
TextSelectionOverlay _createSelectionOverlay() {
|
||||
final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
|
||||
clipboardStatus: _clipboardStatus,
|
||||
context: context,
|
||||
value: _value,
|
||||
debugRequiredFor: widget,
|
||||
toolbarLayerLink: _toolbarLayerLink,
|
||||
startHandleLayerLink: _startHandleLayerLink,
|
||||
endHandleLayerLink: _endHandleLayerLink,
|
||||
renderObject: renderEditable,
|
||||
selectionControls: widget.selectionControls,
|
||||
selectionDelegate: this,
|
||||
dragStartBehavior: DragStartBehavior.start,
|
||||
onSelectionHandleTapped: () {
|
||||
_toggleToolbar();
|
||||
},
|
||||
contextMenuBuilder: widget.contextMenuBuilder == null || kIsWeb
|
||||
? null
|
||||
: (context) {
|
||||
return widget.contextMenuBuilder!(
|
||||
context,
|
||||
_clipboardStatus!.value,
|
||||
copyEnabled
|
||||
? () => copySelection(SelectionChangedCause.toolbar)
|
||||
: null,
|
||||
cutEnabled
|
||||
? () => cutSelection(SelectionChangedCause.toolbar)
|
||||
: null,
|
||||
pasteEnabled
|
||||
? () => pasteText(SelectionChangedCause.toolbar)
|
||||
: null,
|
||||
selectAllEnabled
|
||||
? () => selectAll(SelectionChangedCause.toolbar)
|
||||
: null,
|
||||
_contextMenuAnchors,
|
||||
);
|
||||
},
|
||||
magnifierConfiguration: TextMagnifierConfiguration.disabled,
|
||||
);
|
||||
|
||||
return selectionOverlay;
|
||||
}
|
||||
|
||||
void _toggleToolbar() {
|
||||
final TextSelectionOverlay selectionOverlay =
|
||||
_selectionOverlay ??= _createSelectionOverlay();
|
||||
|
||||
if (selectionOverlay.toolbarIsVisible) {
|
||||
hideToolbar(false);
|
||||
} else {
|
||||
showToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
// When the framework's text editing value changes we should update the text editing
|
||||
// value contained within the selection overlay or we might observe unexpected behavior.
|
||||
void _updateOrDisposeOfSelectionOverlayIfNeeded() {
|
||||
if (_selectionOverlay != null) {
|
||||
if (_hasFocus) {
|
||||
_selectionOverlay!.update(_value);
|
||||
} else {
|
||||
_selectionOverlay!.dispose();
|
||||
_selectionOverlay = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the line heights at the start and end of the selection for the given
|
||||
/// editable.
|
||||
_GlyphHeights _getGlyphHeights() {
|
||||
final TextSelection selection = textEditingValue.selection;
|
||||
|
||||
// Only calculate handle rects if the text in the previous frame
|
||||
// is the same as the text in the current frame. This is done because
|
||||
// widget.renderObject contains the renderEditable from the previous frame.
|
||||
// If the text changed between the current and previous frames then
|
||||
// widget.renderObject.getRectForComposingRange might fail. In cases where
|
||||
// the current frame is different from the previous we fall back to
|
||||
// renderObject.preferredLineHeight.
|
||||
final InlineSpan span = renderEditable.text!;
|
||||
final String prevText = span.toPlainText();
|
||||
final String currText = textEditingValue.text;
|
||||
if (prevText != currText || !selection.isValid || selection.isCollapsed) {
|
||||
return _GlyphHeights(
|
||||
start: renderEditable.preferredLineHeight,
|
||||
end: renderEditable.preferredLineHeight,
|
||||
);
|
||||
}
|
||||
|
||||
final String selectedGraphemes = selection.textInside(currText);
|
||||
final int firstSelectedGraphemeExtent =
|
||||
selectedGraphemes.characters.first.length;
|
||||
final Rect? startCharacterRect =
|
||||
renderEditable.getRectForComposingRange(TextRange(
|
||||
start: selection.start,
|
||||
end: selection.start + firstSelectedGraphemeExtent,
|
||||
));
|
||||
final int lastSelectedGraphemeExtent =
|
||||
selectedGraphemes.characters.last.length;
|
||||
final Rect? endCharacterRect =
|
||||
renderEditable.getRectForComposingRange(TextRange(
|
||||
start: selection.end - lastSelectedGraphemeExtent,
|
||||
end: selection.end,
|
||||
));
|
||||
return _GlyphHeights(
|
||||
start: startCharacterRect?.height ?? renderEditable.preferredLineHeight,
|
||||
end: endCharacterRect?.height ?? renderEditable.preferredLineHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the anchor points for the default context menu.
|
||||
TextSelectionToolbarAnchors get _contextMenuAnchors {
|
||||
if (renderEditable.lastSecondaryTapDownPosition != null) {
|
||||
return TextSelectionToolbarAnchors(
|
||||
primaryAnchor: renderEditable.lastSecondaryTapDownPosition!,
|
||||
);
|
||||
}
|
||||
|
||||
final _GlyphHeights glyphHeights = _getGlyphHeights();
|
||||
final TextSelection selection = textEditingValue.selection;
|
||||
final List<TextSelectionPoint> points =
|
||||
renderEditable.getEndpointsForSelection(selection);
|
||||
return TextSelectionToolbarAnchors.fromSelection(
|
||||
renderBox: renderEditable,
|
||||
startGlyphHeight: glyphHeights.start,
|
||||
endGlyphHeight: glyphHeights.end,
|
||||
selectionEndpoints: points,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Actions(
|
||||
@@ -1018,3 +1160,18 @@ class _Editable extends MultiChildRenderObjectWidget {
|
||||
..setPromptRectRange(promptRectRange);
|
||||
}
|
||||
}
|
||||
|
||||
/// The start and end glyph heights of some range of text.
|
||||
@immutable
|
||||
class _GlyphHeights {
|
||||
const _GlyphHeights({
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
/// The glyph height of the first line.
|
||||
final double start;
|
||||
|
||||
/// The glyph height of the last line.
|
||||
final double end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user