mirror of
https://github.com/niri-wm/niri.git
synced 2026-06-22 02:01:55 +07:00
Implement horizontal touchpad swipe
This commit is contained in:
+46
-7
@@ -1185,13 +1185,7 @@ impl State {
|
||||
|
||||
fn on_gesture_swipe_begin<I: InputBackend>(&mut self, event: I::GestureSwipeBeginEvent) {
|
||||
if event.fingers() == 3 {
|
||||
if let Some(output) = self.niri.output_under_cursor() {
|
||||
self.niri.layout.workspace_switch_gesture_begin(&output);
|
||||
|
||||
// FIXME: granular. This one is awkward because this can cancel a gesture on
|
||||
// multiple other outputs in theory.
|
||||
self.niri.queue_redraw_all();
|
||||
}
|
||||
self.niri.gesture_swipe_3f_cumulative = Some((0., 0.));
|
||||
|
||||
// We handled this event.
|
||||
return;
|
||||
@@ -1218,21 +1212,54 @@ impl State {
|
||||
where
|
||||
I::Device: 'static,
|
||||
{
|
||||
let mut delta_x = event.delta_x();
|
||||
let mut delta_y = event.delta_y();
|
||||
|
||||
let device = event.device();
|
||||
if let Some(device) = (&device as &dyn Any).downcast_ref::<input::Device>() {
|
||||
if device.config_scroll_natural_scroll_enabled() {
|
||||
delta_x = -delta_x;
|
||||
delta_y = -delta_y;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((cx, cy)) = &mut self.niri.gesture_swipe_3f_cumulative {
|
||||
*cx += delta_x;
|
||||
*cy += delta_y;
|
||||
|
||||
// Check if the gesture moved far enough to decide. Threshold copied from GNOME Shell.
|
||||
let (cx, cy) = (*cx, *cy);
|
||||
if cx * cx + cy * cy >= 16. * 16. {
|
||||
self.niri.gesture_swipe_3f_cumulative = None;
|
||||
|
||||
if let Some(output) = self.niri.output_under_cursor() {
|
||||
if cx.abs() > cy.abs() {
|
||||
self.niri.layout.view_offset_gesture_begin(&output);
|
||||
} else {
|
||||
self.niri.layout.workspace_switch_gesture_begin(&output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut handled = false;
|
||||
let res = self.niri.layout.workspace_switch_gesture_update(delta_y);
|
||||
if let Some(output) = res {
|
||||
if let Some(output) = output {
|
||||
self.niri.queue_redraw(output);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
|
||||
let res = self.niri.layout.view_offset_gesture_update(delta_x);
|
||||
if let Some(output) = res {
|
||||
if let Some(output) = output {
|
||||
self.niri.queue_redraw(output);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if handled {
|
||||
// We handled this event.
|
||||
return;
|
||||
}
|
||||
@@ -1253,13 +1280,25 @@ impl State {
|
||||
}
|
||||
|
||||
fn on_gesture_swipe_end<I: InputBackend>(&mut self, event: I::GestureSwipeEndEvent) {
|
||||
self.niri.gesture_swipe_3f_cumulative = None;
|
||||
|
||||
let mut handled = false;
|
||||
let res = self
|
||||
.niri
|
||||
.layout
|
||||
.workspace_switch_gesture_end(event.cancelled());
|
||||
if let Some(output) = res {
|
||||
self.niri.queue_redraw(output);
|
||||
handled = true;
|
||||
}
|
||||
|
||||
let res = self.niri.layout.view_offset_gesture_end(event.cancelled());
|
||||
if let Some(output) = res {
|
||||
self.niri.queue_redraw(output);
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if handled {
|
||||
// We handled this event.
|
||||
return;
|
||||
}
|
||||
|
||||
+95
-4
@@ -1675,6 +1675,63 @@ impl<W: LayoutElement> Layout<W> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_begin(&mut self, output: &Output) {
|
||||
let monitors = match &mut self.monitor_set {
|
||||
MonitorSet::Normal { monitors, .. } => monitors,
|
||||
MonitorSet::NoOutputs { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
for monitor in monitors {
|
||||
for (idx, ws) in monitor.workspaces.iter_mut().enumerate() {
|
||||
// Cancel the gesture on other workspaces.
|
||||
if &monitor.output != output || idx != monitor.active_workspace_idx {
|
||||
ws.view_offset_gesture_end(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
ws.view_offset_gesture_begin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_update(&mut self, delta_x: f64) -> Option<Option<Output>> {
|
||||
let monitors = match &mut self.monitor_set {
|
||||
MonitorSet::Normal { monitors, .. } => monitors,
|
||||
MonitorSet::NoOutputs { .. } => return None,
|
||||
};
|
||||
|
||||
for monitor in monitors {
|
||||
for ws in &mut monitor.workspaces {
|
||||
if let Some(refresh) = ws.view_offset_gesture_update(delta_x) {
|
||||
if refresh {
|
||||
return Some(Some(monitor.output.clone()));
|
||||
} else {
|
||||
return Some(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_end(&mut self, cancelled: bool) -> Option<Output> {
|
||||
let monitors = match &mut self.monitor_set {
|
||||
MonitorSet::Normal { monitors, .. } => monitors,
|
||||
MonitorSet::NoOutputs { .. } => return None,
|
||||
};
|
||||
|
||||
for monitor in monitors {
|
||||
for ws in &mut monitor.workspaces {
|
||||
if ws.view_offset_gesture_end(cancelled) {
|
||||
return Some(monitor.output.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn move_workspace_down(&mut self) {
|
||||
let Some(monitor) = self.active_monitor() else {
|
||||
return;
|
||||
@@ -1724,25 +1781,31 @@ impl<W: LayoutElement> Layout<W> {
|
||||
}
|
||||
|
||||
impl Layout<Window> {
|
||||
pub fn refresh(&self) {
|
||||
pub fn refresh(&mut self) {
|
||||
let _span = tracy_client::span!("MonitorSet::refresh");
|
||||
|
||||
match &self.monitor_set {
|
||||
match &mut self.monitor_set {
|
||||
MonitorSet::Normal {
|
||||
monitors,
|
||||
active_monitor_idx,
|
||||
..
|
||||
} => {
|
||||
for (idx, mon) in monitors.iter().enumerate() {
|
||||
for (idx, mon) in monitors.iter_mut().enumerate() {
|
||||
let is_active = idx == *active_monitor_idx;
|
||||
for ws in &mon.workspaces {
|
||||
for (ws_idx, ws) in mon.workspaces.iter_mut().enumerate() {
|
||||
ws.refresh(is_active);
|
||||
|
||||
// Cancel the view offset gesture after workspace switches, moves, etc.
|
||||
if ws_idx != mon.active_workspace_idx {
|
||||
ws.view_offset_gesture_end(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MonitorSet::NoOutputs { workspaces, .. } => {
|
||||
for ws in workspaces {
|
||||
ws.refresh(false);
|
||||
ws.view_offset_gesture_end(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1931,6 +1994,10 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn arbitrary_view_offset_gesture_delta() -> impl Strategy<Value = f64> {
|
||||
prop_oneof![(-10f64..10f64), (-50000f64..50000f64),]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Arbitrary)]
|
||||
enum Op {
|
||||
AddOutput(#[proptest(strategy = "1..=5usize")] usize),
|
||||
@@ -1996,6 +2063,15 @@ mod tests {
|
||||
SetWindowHeight(#[proptest(strategy = "arbitrary_size_change()")] SizeChange),
|
||||
Communicate(#[proptest(strategy = "1..=5usize")] usize),
|
||||
MoveWorkspaceToOutput(#[proptest(strategy = "1..=5u8")] u8),
|
||||
ViewOffsetGestureBegin {
|
||||
#[proptest(strategy = "1..=5usize")]
|
||||
output_idx: usize,
|
||||
},
|
||||
ViewOffsetGestureUpdate {
|
||||
#[proptest(strategy = "arbitrary_view_offset_gesture_delta()")]
|
||||
delta: f64,
|
||||
},
|
||||
ViewOffsetGestureEnd,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
@@ -2231,6 +2307,21 @@ mod tests {
|
||||
|
||||
layout.move_workspace_to_output(&output);
|
||||
}
|
||||
Op::ViewOffsetGestureBegin { output_idx: id } => {
|
||||
let name = format!("output{id}");
|
||||
let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
layout.view_offset_gesture_begin(&output);
|
||||
}
|
||||
Op::ViewOffsetGestureUpdate { delta } => {
|
||||
layout.view_offset_gesture_update(delta);
|
||||
}
|
||||
Op::ViewOffsetGestureEnd => {
|
||||
// We don't handle cancels in this gesture.
|
||||
layout.view_offset_gesture_end(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+188
-23
@@ -55,8 +55,8 @@ pub struct Workspace<W: LayoutElement> {
|
||||
/// for natural handling of fullscreen windows, which must ignore work area padding.
|
||||
view_offset: i32,
|
||||
|
||||
/// Animation of the view offset, if one is currently ongoing.
|
||||
view_offset_anim: Option<Animation>,
|
||||
/// Adjustment of the view offset, if one is currently ongoing.
|
||||
view_offset_adj: Option<ViewOffsetAdjustment>,
|
||||
|
||||
/// Whether to activate the previous, rather than the next, column upon column removal.
|
||||
///
|
||||
@@ -81,6 +81,17 @@ niri_render_elements! {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ViewOffsetAdjustment {
|
||||
Animation(Animation),
|
||||
Gesture(ViewGesture),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ViewGesture {
|
||||
current_view_offset: f64,
|
||||
}
|
||||
|
||||
/// Width of a column.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ColumnWidth {
|
||||
@@ -160,6 +171,15 @@ impl OutputId {
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewOffsetAdjustment {
|
||||
pub fn current_view_offset(&self) -> f64 {
|
||||
match self {
|
||||
ViewOffsetAdjustment::Animation(anim) => anim.value(),
|
||||
ViewOffsetAdjustment::Gesture(gesture) => gesture.current_view_offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColumnWidth {
|
||||
fn resolve(self, options: &Options, view_width: i32) -> i32 {
|
||||
match self {
|
||||
@@ -192,7 +212,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
columns: vec![],
|
||||
active_column_idx: 0,
|
||||
view_offset: 0,
|
||||
view_offset_anim: None,
|
||||
view_offset_adj: None,
|
||||
activate_prev_column_on_removal: false,
|
||||
options,
|
||||
}
|
||||
@@ -207,22 +227,21 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
columns: vec![],
|
||||
active_column_idx: 0,
|
||||
view_offset: 0,
|
||||
view_offset_anim: None,
|
||||
view_offset_adj: None,
|
||||
activate_prev_column_on_removal: false,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn advance_animations(&mut self, current_time: Duration, is_active: bool) {
|
||||
match &mut self.view_offset_anim {
|
||||
Some(anim) => {
|
||||
anim.set_current_time(current_time);
|
||||
self.view_offset = anim.value().round() as i32;
|
||||
if anim.is_done() {
|
||||
self.view_offset_anim = None;
|
||||
}
|
||||
if let Some(ViewOffsetAdjustment::Animation(anim)) = &mut self.view_offset_adj {
|
||||
anim.set_current_time(current_time);
|
||||
self.view_offset = anim.value().round() as i32;
|
||||
if anim.is_done() {
|
||||
self.view_offset_adj = None;
|
||||
}
|
||||
None => (),
|
||||
} else if let Some(ViewOffsetAdjustment::Gesture(gesture)) = &self.view_offset_adj {
|
||||
self.view_offset = gesture.current_view_offset.round() as i32;
|
||||
}
|
||||
|
||||
for (col_idx, col) in self.columns.iter_mut().enumerate() {
|
||||
@@ -232,7 +251,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
}
|
||||
|
||||
pub fn are_animations_ongoing(&self) -> bool {
|
||||
self.view_offset_anim.is_some() || self.columns.iter().any(Column::are_animations_ongoing)
|
||||
self.view_offset_adj.is_some() || self.columns.iter().any(Column::are_animations_ongoing)
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, options: Rc<Options>) {
|
||||
@@ -382,8 +401,8 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
|
||||
let new_col_x = self.column_x(idx);
|
||||
|
||||
let final_x = if let Some(anim) = &self.view_offset_anim {
|
||||
current_x - self.view_offset + anim.to().round() as i32
|
||||
let final_x = if let Some(adj) = &self.view_offset_adj {
|
||||
current_x - self.view_offset + adj.current_view_offset().round() as i32
|
||||
} else {
|
||||
current_x
|
||||
};
|
||||
@@ -406,7 +425,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
self.view_offset = from_view_offset;
|
||||
|
||||
// If we're already animating towards that, don't restart it.
|
||||
if let Some(anim) = &self.view_offset_anim {
|
||||
if let Some(ViewOffsetAdjustment::Animation(anim)) = &self.view_offset_adj {
|
||||
if anim.value().round() as i32 == self.view_offset
|
||||
&& anim.to().round() as i32 == new_view_offset
|
||||
{
|
||||
@@ -416,16 +435,16 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
|
||||
// If our view offset is already this, we don't need to do anything.
|
||||
if self.view_offset == new_view_offset {
|
||||
self.view_offset_anim = None;
|
||||
self.view_offset_adj = None;
|
||||
return;
|
||||
}
|
||||
|
||||
self.view_offset_anim = Some(Animation::new(
|
||||
self.view_offset_adj = Some(ViewOffsetAdjustment::Animation(Animation::new(
|
||||
self.view_offset as f64,
|
||||
new_view_offset as f64,
|
||||
self.options.animations.horizontal_view_movement,
|
||||
niri_config::Animation::default_horizontal_view_movement(),
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
fn animate_view_offset_to_column_fit(&mut self, current_x: i32, idx: usize) {
|
||||
@@ -594,7 +613,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
// exclusive zones.
|
||||
self.view_offset = self.compute_new_view_offset_for_column(self.column_x(0), 0);
|
||||
}
|
||||
self.view_offset_anim = None;
|
||||
self.view_offset_adj = None;
|
||||
}
|
||||
|
||||
self.activate_column(idx);
|
||||
@@ -666,7 +685,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
// exclusive zones.
|
||||
self.view_offset = self.compute_new_view_offset_for_column(self.column_x(0), 0);
|
||||
}
|
||||
self.view_offset_anim = None;
|
||||
self.view_offset_adj = None;
|
||||
}
|
||||
|
||||
self.activate_column(idx);
|
||||
@@ -776,7 +795,9 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
column.update_window(window);
|
||||
column.update_tile_sizes();
|
||||
|
||||
if idx == self.active_column_idx {
|
||||
if idx == self.active_column_idx
|
||||
&& !matches!(self.view_offset_adj, Some(ViewOffsetAdjustment::Gesture(_)))
|
||||
{
|
||||
// We might need to move the view to ensure the resized window is still visible.
|
||||
let current_x = self.view_pos();
|
||||
|
||||
@@ -1171,7 +1192,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.view_offset_anim.is_some() {
|
||||
if self.view_offset_adj.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1210,6 +1231,150 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
|
||||
rv
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_begin(&mut self) {
|
||||
if self.columns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let gesture = ViewGesture {
|
||||
current_view_offset: self.view_offset as f64,
|
||||
};
|
||||
self.view_offset_adj = Some(ViewOffsetAdjustment::Gesture(gesture));
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_update(&mut self, delta_x: f64) -> Option<bool> {
|
||||
let Some(ViewOffsetAdjustment::Gesture(gesture)) = &mut self.view_offset_adj else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut new_offset = gesture.current_view_offset + delta_x;
|
||||
gesture.current_view_offset = new_offset;
|
||||
|
||||
if self.columns.is_empty() {
|
||||
return Some(true);
|
||||
}
|
||||
|
||||
// Switch the next window to be active, if necessary.
|
||||
//
|
||||
// The logic here is similar to PaperWM. The idea is: make the next window (in the
|
||||
// direction of the gesture) active when it becomes "more visible" than the current active
|
||||
// window.
|
||||
|
||||
// Make an iterator over column indices into the gesture direction.
|
||||
let mut idxs_before = (0..=self.active_column_idx).rev();
|
||||
let mut idxs_after = self.active_column_idx..self.columns.len();
|
||||
let next_column_idxs = if delta_x < 0. {
|
||||
&mut idxs_before as &mut dyn Iterator<Item = usize>
|
||||
} else {
|
||||
&mut idxs_after as &mut dyn Iterator<Item = usize>
|
||||
};
|
||||
|
||||
let mut last = None;
|
||||
for col_idx in next_column_idxs {
|
||||
let col = &self.columns[col_idx];
|
||||
let col_x = self.column_x(col_idx);
|
||||
let col_w = col.width();
|
||||
|
||||
let mut area_for_col = if col.is_fullscreen {
|
||||
Rectangle::from_loc_and_size((0, 0), self.view_size)
|
||||
} else {
|
||||
self.working_area
|
||||
};
|
||||
|
||||
if let Some((last_col_x, _)) = last {
|
||||
area_for_col.loc.x += last_col_x + new_offset.round() as i32;
|
||||
} else {
|
||||
// First iteration of the loop; col_idx == self.active_column_idx.
|
||||
area_for_col.loc.x += col_x + new_offset.round() as i32;
|
||||
}
|
||||
|
||||
// Check if the column is fully visible.
|
||||
if area_for_col.loc.x <= col_x
|
||||
&& col_x + col_w <= area_for_col.loc.x + area_for_col.size.w
|
||||
{
|
||||
// Make it the new active column.
|
||||
if let Some((last_col_x, _)) = last {
|
||||
new_offset += (last_col_x - col_x) as f64;
|
||||
self.active_column_idx = col_idx;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if the column is already past the working area.
|
||||
if (delta_x >= 0. && area_for_col.loc.x + area_for_col.size.w <= col_x)
|
||||
|| (delta_x < 0. && col_x + col_w <= area_for_col.loc.x)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Compute the visible width (inside the working area).
|
||||
let visible_width = col_w
|
||||
- max(0, area_for_col.loc.x - col_x)
|
||||
- max(
|
||||
0,
|
||||
(col_x + col_w) - (area_for_col.loc.x + area_for_col.size.w),
|
||||
);
|
||||
let visible_ratio = if col_w == 0 {
|
||||
1.
|
||||
} else {
|
||||
visible_width as f64 / col_w as f64
|
||||
};
|
||||
|
||||
if let Some((last_col_x, last_ratio)) = last {
|
||||
// Check if we reached the first visible window.
|
||||
if area_for_col.loc.x < col_x + col_w
|
||||
&& col_x < area_for_col.loc.x + area_for_col.size.w
|
||||
{
|
||||
// If it's more visible than the last one, make it active.
|
||||
if visible_ratio >= last_ratio {
|
||||
new_offset += (last_col_x - col_x) as f64;
|
||||
self.active_column_idx = col_idx;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Still working through invisible windows.
|
||||
new_offset += (last_col_x - col_x) as f64;
|
||||
self.active_column_idx = col_idx;
|
||||
}
|
||||
|
||||
last = Some((col_x, visible_ratio));
|
||||
}
|
||||
|
||||
let Some(ViewOffsetAdjustment::Gesture(gesture)) = &mut self.view_offset_adj else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
gesture.current_view_offset = new_offset;
|
||||
Some(true)
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_end(&mut self, _cancelled: bool) -> bool {
|
||||
let Some(ViewOffsetAdjustment::Gesture(gesture)) = &self.view_offset_adj else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// We do not handle cancelling, just like GNOME Shell doesn't. For this gesture, proper
|
||||
// cancelling would require keeping track of the original active column, and then updating
|
||||
// it in all the right places (adding columns, removing columns, etc.) -- quite a bit of
|
||||
// effort and bug potential.
|
||||
|
||||
// FIXME: keep track of gesture velocity and use it to compute the final point and
|
||||
// to animate to it.
|
||||
let offset = gesture.current_view_offset.round() as i32;
|
||||
|
||||
self.view_offset = offset;
|
||||
self.view_offset_adj = None;
|
||||
|
||||
if !self.columns.is_empty() {
|
||||
let current_x = self.view_pos();
|
||||
self.animate_view_offset_to_column(current_x, self.active_column_idx, None);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace<Window> {
|
||||
|
||||
@@ -192,6 +192,7 @@ pub struct Niri {
|
||||
pub dnd_icon: Option<WlSurface>,
|
||||
pub pointer_focus: Option<PointerFocus>,
|
||||
pub tablet_cursor_location: Option<Point<f64, Logical>>,
|
||||
pub gesture_swipe_3f_cumulative: Option<(f64, f64)>,
|
||||
|
||||
pub lock_state: LockState,
|
||||
|
||||
@@ -1048,6 +1049,7 @@ impl Niri {
|
||||
dnd_icon: None,
|
||||
pointer_focus: None,
|
||||
tablet_cursor_location: None,
|
||||
gesture_swipe_3f_cumulative: None,
|
||||
|
||||
lock_state: LockState::Unlocked,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user