Merge pull request #1878 from thelounge/yamanickill/refactor-context-menu
Pull context menu code out of lounge.js and make it more generic
This commit is contained in:
commit
ce08201d13
85
client/js/contextMenu.js
Normal file
85
client/js/contextMenu.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use strict";
|
||||||
|
const $ = require("jquery");
|
||||||
|
const templates = require("../views");
|
||||||
|
let contextMenu, contextMenuContainer;
|
||||||
|
|
||||||
|
module.exports = class ContextMenu {
|
||||||
|
constructor(contextMenuItems, contextMenuActions, selectedElement, event) {
|
||||||
|
this.contextMenuItems = contextMenuItems;
|
||||||
|
this.contextMenuActions = contextMenuActions;
|
||||||
|
this.selectedElement = selectedElement;
|
||||||
|
this.event = event;
|
||||||
|
|
||||||
|
contextMenuContainer = $("#context-menu-container");
|
||||||
|
contextMenu = $("#context-menu");
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
showContextMenu(this.contextMenuItems, this.selectedElement, this.event);
|
||||||
|
this.bindEvents();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const contextMenuActions = this.contextMenuActions;
|
||||||
|
|
||||||
|
contextMenuActions.execute = (id, ...args) => contextMenuActions[id] && contextMenuActions[id](...args);
|
||||||
|
|
||||||
|
contextMenu.find(".context-menu-item").on("click", function() {
|
||||||
|
const $this = $(this);
|
||||||
|
const itemData = $this.data("data");
|
||||||
|
const contextAction = $this.data("action");
|
||||||
|
contextMenuActions.execute(contextAction, itemData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function showContextMenu(contextMenuItems, selectedElement, event) {
|
||||||
|
const target = $(event.currentTarget);
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
for (const item of contextMenuItems) {
|
||||||
|
if (item.check(target)) {
|
||||||
|
if (item.divider) {
|
||||||
|
output += templates.contextmenu_divider();
|
||||||
|
} else {
|
||||||
|
output += templates.contextmenu_item({
|
||||||
|
class: typeof item.className === "function" ? item.className(target) : item.className,
|
||||||
|
action: item.actionId,
|
||||||
|
text: typeof item.displayName === "function" ? item.displayName(target) : item.displayName,
|
||||||
|
data: typeof item.data === "function" ? item.data(target) : item.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenuContainer.show();
|
||||||
|
contextMenu
|
||||||
|
.html(output)
|
||||||
|
.css(positionContextMenu(selectedElement, event));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionContextMenu(selectedElement, e) {
|
||||||
|
let offset;
|
||||||
|
const menuWidth = contextMenu.outerWidth();
|
||||||
|
const menuHeight = contextMenu.outerHeight();
|
||||||
|
|
||||||
|
if (selectedElement.hasClass("menu")) {
|
||||||
|
offset = selectedElement.offset();
|
||||||
|
offset.left -= menuWidth - selectedElement.outerWidth();
|
||||||
|
offset.top += selectedElement.outerHeight();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = {left: e.pageX, top: e.pageY};
|
||||||
|
|
||||||
|
if ((window.innerWidth - offset.left) < menuWidth) {
|
||||||
|
offset.left = window.innerWidth - menuWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((window.innerHeight - offset.top) < menuHeight) {
|
||||||
|
offset.top = window.innerHeight - menuHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset;
|
||||||
|
}
|
213
client/js/contextMenuFactory.js
Normal file
213
client/js/contextMenuFactory.js
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"use strict";
|
||||||
|
const $ = require("jquery");
|
||||||
|
const socket = require("./socket");
|
||||||
|
const utils = require("./utils");
|
||||||
|
const JoinChannel = require("./join-channel");
|
||||||
|
const ContextMenu = require("./contextMenu");
|
||||||
|
const contextMenuActions = [];
|
||||||
|
const contextMenuItems = [];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addContextMenuItem,
|
||||||
|
createContextMenu,
|
||||||
|
};
|
||||||
|
|
||||||
|
addDefaultItems();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for adding context menu items. eg:
|
||||||
|
*
|
||||||
|
* addContextMenuItem({
|
||||||
|
* check: (target) => target.hasClass("user"),
|
||||||
|
* className: "customItemName",
|
||||||
|
* data: (target) => target.data("name"),
|
||||||
|
* displayName: "Do something",
|
||||||
|
* callback: (name) => console.log(name), // print the name of the user to console
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @param opts
|
||||||
|
* @param {function(Object)} [opts.check] - Function to check whether item should show on the context menu, called with the target jquery element, shows if return is truthy
|
||||||
|
* @param {string|function(Object)} opts.className - class name for the menu item, should be prefixed for non-default menu items (if function, called with jquery element, and uses return value)
|
||||||
|
* @param {string|function(Object)} opts.data - data that will be sent to the callback function (if function, called with jquery element, and uses return value)
|
||||||
|
* @param {string|function(Object)} opts.displayName - text to display on the menu item (if function, called with jquery element, and uses return value)
|
||||||
|
* @param {function(Object)} opts.callback - Function to call when the context menu item is clicked, called with the data requested in opts.data
|
||||||
|
*/
|
||||||
|
function addContextMenuItem(opts) {
|
||||||
|
opts.check = opts.check || (() => true);
|
||||||
|
opts.actionId = contextMenuActions.push(opts.callback) - 1;
|
||||||
|
contextMenuItems.push(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContextDivider(opts) {
|
||||||
|
opts.check = opts.check || (() => true);
|
||||||
|
opts.divider = true;
|
||||||
|
contextMenuItems.push(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContextMenu(that, event) {
|
||||||
|
return new ContextMenu(contextMenuItems, contextMenuActions, that, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWhoisItem() {
|
||||||
|
function whois(itemData) {
|
||||||
|
const chan = utils.findCurrentNetworkChan(itemData);
|
||||||
|
|
||||||
|
if (chan.length) {
|
||||||
|
chan.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("input", {
|
||||||
|
target: $("#chat").data("id"),
|
||||||
|
text: "/whois " + itemData,
|
||||||
|
});
|
||||||
|
|
||||||
|
$(`.channel.active .userlist .user[data-name="${itemData}"]`).trigger("click");
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("user"),
|
||||||
|
className: "user",
|
||||||
|
displayName: (target) => target.data("name"),
|
||||||
|
data: (target) => target.data("name"),
|
||||||
|
callback: whois,
|
||||||
|
});
|
||||||
|
|
||||||
|
addContextDivider({
|
||||||
|
check: (target) => target.hasClass("user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("user"),
|
||||||
|
className: "action-whois",
|
||||||
|
displayName: "User information",
|
||||||
|
data: (target) => target.data("name"),
|
||||||
|
callback: whois,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addQueryItem() {
|
||||||
|
function query(itemData) {
|
||||||
|
const chan = utils.findCurrentNetworkChan(itemData);
|
||||||
|
|
||||||
|
if (chan.length) {
|
||||||
|
chan.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("input", {
|
||||||
|
target: $("#chat").data("id"),
|
||||||
|
text: "/query " + itemData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("user"),
|
||||||
|
className: "action-query",
|
||||||
|
displayName: "Direct messages",
|
||||||
|
data: (target) => target.data("name"),
|
||||||
|
callback: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKickItem() {
|
||||||
|
function kick(itemData) {
|
||||||
|
socket.emit("input", {
|
||||||
|
target: $("#chat").data("id"),
|
||||||
|
text: "/kick " + itemData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => utils.hasRoleInChannel(target.closest(".chan"), ["op"]) && target.closest(".chan").data("type") === "channel",
|
||||||
|
className: "action-kick",
|
||||||
|
displayName: "Kick",
|
||||||
|
data: (target) => target.data("name"),
|
||||||
|
callback: kick,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFocusItem() {
|
||||||
|
function focusChan(itemData) {
|
||||||
|
$(`.networks .chan[data-target="${itemData}"]`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const getClass = (target) => {
|
||||||
|
if (target.hasClass("lobby")) {
|
||||||
|
return "network";
|
||||||
|
} else if (target.hasClass("query")) {
|
||||||
|
return "query";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "chan";
|
||||||
|
};
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("chan"),
|
||||||
|
className: getClass,
|
||||||
|
displayName: (target) => target.attr("aria-label"),
|
||||||
|
data: (target) => target.data("target"),
|
||||||
|
callback: focusChan,
|
||||||
|
});
|
||||||
|
|
||||||
|
addContextDivider({
|
||||||
|
check: (target) => target.hasClass("chan"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChannelListItem() {
|
||||||
|
function list(itemData) {
|
||||||
|
socket.emit("input", {
|
||||||
|
target: itemData,
|
||||||
|
text: "/list",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("lobby"),
|
||||||
|
className: "list",
|
||||||
|
displayName: "List all channels",
|
||||||
|
data: (target) => target.data("id"),
|
||||||
|
callback: list,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBanListItem() {
|
||||||
|
function banlist(itemData) {
|
||||||
|
socket.emit("input", {
|
||||||
|
target: itemData,
|
||||||
|
text: "/banlist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("channel"),
|
||||||
|
className: "list",
|
||||||
|
displayName: "List banned users",
|
||||||
|
data: (target) => target.data("id"),
|
||||||
|
callback: banlist,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addJoinItem() {
|
||||||
|
function openJoinForm(itemData) {
|
||||||
|
const network = $(`#join-channel-${itemData}`).closest(".network");
|
||||||
|
JoinChannel.openForm(network);
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("lobby"),
|
||||||
|
className: "join",
|
||||||
|
displayName: "Join a channel…",
|
||||||
|
data: (target) => target.data("id"),
|
||||||
|
callback: openJoinForm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDefaultItems() {
|
||||||
|
addWhoisItem();
|
||||||
|
addQueryItem();
|
||||||
|
addKickItem();
|
||||||
|
addFocusItem();
|
||||||
|
addChannelListItem();
|
||||||
|
addBanListItem();
|
||||||
|
addJoinItem();
|
||||||
|
}
|
@ -19,7 +19,8 @@ require("./webpush");
|
|||||||
require("./keybinds");
|
require("./keybinds");
|
||||||
require("./clipboard");
|
require("./clipboard");
|
||||||
const Changelog = require("./socket-events/changelog");
|
const Changelog = require("./socket-events/changelog");
|
||||||
const JoinChannel = require("./join-channel");
|
const contextMenuFactory = require("./contextMenuFactory");
|
||||||
|
const contextMenuContainer = $("#context-menu-container");
|
||||||
|
|
||||||
$(function() {
|
$(function() {
|
||||||
const sidebar = $("#sidebar, #footer");
|
const sidebar = $("#sidebar, #footer");
|
||||||
@ -28,8 +29,6 @@ $(function() {
|
|||||||
$(document.body).data("app-name", document.title);
|
$(document.body).data("app-name", document.title);
|
||||||
|
|
||||||
const viewport = $("#viewport");
|
const viewport = $("#viewport");
|
||||||
const contextMenuContainer = $("#context-menu-container");
|
|
||||||
const contextMenu = $("#context-menu");
|
|
||||||
|
|
||||||
function storeSidebarVisibility(name, state) {
|
function storeSidebarVisibility(name, state) {
|
||||||
if ($(window).outerWidth() < utils.mobileViewportPixels) {
|
if ($(window).outerWidth() < utils.mobileViewportPixels) {
|
||||||
@ -62,128 +61,8 @@ $(function() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function positionContextMenu(that, e) {
|
|
||||||
let offset;
|
|
||||||
const menuWidth = contextMenu.outerWidth();
|
|
||||||
const menuHeight = contextMenu.outerHeight();
|
|
||||||
|
|
||||||
if (that.hasClass("menu")) {
|
|
||||||
offset = that.offset();
|
|
||||||
offset.left -= menuWidth - that.outerWidth();
|
|
||||||
offset.top += that.outerHeight();
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
offset = {left: e.pageX, top: e.pageY};
|
|
||||||
|
|
||||||
if ((window.innerWidth - offset.left) < menuWidth) {
|
|
||||||
offset.left = window.innerWidth - menuWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((window.innerHeight - offset.top) < menuHeight) {
|
|
||||||
offset.top = window.innerHeight - menuHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showContextMenu(that, e) {
|
|
||||||
const target = $(e.currentTarget);
|
|
||||||
let output = "";
|
|
||||||
|
|
||||||
if (target.hasClass("user")) {
|
|
||||||
output = templates.contextmenu_item({
|
|
||||||
class: "user",
|
|
||||||
action: "whois",
|
|
||||||
text: target.text(),
|
|
||||||
data: target.data("name"),
|
|
||||||
});
|
|
||||||
output += templates.contextmenu_divider();
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "action-whois",
|
|
||||||
action: "whois",
|
|
||||||
text: "User information",
|
|
||||||
data: target.data("name"),
|
|
||||||
});
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "action-query",
|
|
||||||
action: "query",
|
|
||||||
text: "Direct messages",
|
|
||||||
data: target.data("name"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const channel = target.closest(".chan");
|
|
||||||
|
|
||||||
if (utils.hasRoleInChannel(channel, ["op"]) && channel.data("type") === "channel") {
|
|
||||||
output += templates.contextmenu_divider();
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "action-kick",
|
|
||||||
action: "kick",
|
|
||||||
text: "Kick",
|
|
||||||
data: target.data("name"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (target.hasClass("chan")) {
|
|
||||||
let itemClass;
|
|
||||||
|
|
||||||
if (target.hasClass("lobby")) {
|
|
||||||
itemClass = "network";
|
|
||||||
} else if (target.hasClass("query")) {
|
|
||||||
itemClass = "query";
|
|
||||||
} else {
|
|
||||||
itemClass = "chan";
|
|
||||||
}
|
|
||||||
|
|
||||||
output = templates.contextmenu_item({
|
|
||||||
class: itemClass,
|
|
||||||
action: "focusChan",
|
|
||||||
text: target.attr("aria-label"),
|
|
||||||
data: target.data("target"),
|
|
||||||
});
|
|
||||||
output += templates.contextmenu_divider();
|
|
||||||
|
|
||||||
if (target.hasClass("lobby")) {
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "list",
|
|
||||||
action: "list",
|
|
||||||
text: "List all channels",
|
|
||||||
data: target.data("id"),
|
|
||||||
});
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "join",
|
|
||||||
action: "join",
|
|
||||||
text: "Join a channel…",
|
|
||||||
data: target.data("id"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.hasClass("channel")) {
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "list",
|
|
||||||
action: "banlist",
|
|
||||||
text: "List banned users",
|
|
||||||
data: target.data("id"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
output += templates.contextmenu_item({
|
|
||||||
class: "close",
|
|
||||||
action: "close",
|
|
||||||
text: target.hasClass("lobby") ? "Disconnect" : target.hasClass("channel") ? "Leave" : "Close",
|
|
||||||
data: target.data("target"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
contextMenuContainer.show();
|
|
||||||
contextMenu
|
|
||||||
.html(output)
|
|
||||||
.css(positionContextMenu($(that), e));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
viewport.on("contextmenu", ".network .chan", function(e) {
|
viewport.on("contextmenu", ".network .chan", function(e) {
|
||||||
return showContextMenu(this, e);
|
return contextMenuFactory.createContextMenu($(this), e).show();
|
||||||
});
|
});
|
||||||
|
|
||||||
viewport.on("click contextmenu", ".user", function(e) {
|
viewport.on("click contextmenu", ".user", function(e) {
|
||||||
@ -193,12 +72,12 @@ $(function() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return showContextMenu(this, e);
|
return contextMenuFactory.createContextMenu($(this), e).show();
|
||||||
});
|
});
|
||||||
|
|
||||||
viewport.on("click", "#chat .menu", function(e) {
|
viewport.on("click", "#chat .menu", function(e) {
|
||||||
e.currentTarget = $(`#sidebar .chan[data-id="${$(this).closest(".chan").data("id")}"]`)[0];
|
e.currentTarget = $(`#sidebar .chan[data-id="${$(this).closest(".chan").data("id")}"]`)[0];
|
||||||
return showContextMenu(this, e);
|
return contextMenuFactory.createContextMenu($(this), e).show();
|
||||||
});
|
});
|
||||||
|
|
||||||
contextMenuContainer.on("click contextmenu", function() {
|
contextMenuContainer.on("click contextmenu", function() {
|
||||||
@ -509,70 +388,22 @@ $(function() {
|
|||||||
closeChan($(this).closest(".chan"));
|
closeChan($(this).closest(".chan"));
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMenuActions = {
|
const getCloseDisplay = (target) => {
|
||||||
join(itemData) {
|
if (target.hasClass("lobby")) {
|
||||||
const network = $(`#join-channel-${itemData}`).closest(".network");
|
return "Disconnect";
|
||||||
JoinChannel.openForm(network);
|
} else if (target.hasClass("channel")) {
|
||||||
},
|
return "Leave";
|
||||||
close(itemData) {
|
}
|
||||||
closeChan($(`.networks .chan[data-target="${itemData}"]`));
|
|
||||||
},
|
|
||||||
focusChan(itemData) {
|
|
||||||
$(`.networks .chan[data-target="${itemData}"]`).trigger("click");
|
|
||||||
},
|
|
||||||
list(itemData) {
|
|
||||||
socket.emit("input", {
|
|
||||||
target: itemData,
|
|
||||||
text: "/list",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
banlist(itemData) {
|
|
||||||
socket.emit("input", {
|
|
||||||
target: itemData,
|
|
||||||
text: "/banlist",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
whois(itemData) {
|
|
||||||
const chan = utils.findCurrentNetworkChan(itemData);
|
|
||||||
|
|
||||||
if (chan.length) {
|
return "Close";
|
||||||
chan.trigger("click");
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("input", {
|
|
||||||
target: $("#chat").data("id"),
|
|
||||||
text: "/whois " + itemData,
|
|
||||||
});
|
|
||||||
|
|
||||||
$(`.channel.active .userlist .user[data-name="${itemData}"]`).trigger("click");
|
|
||||||
},
|
|
||||||
query(itemData) {
|
|
||||||
const chan = utils.findCurrentNetworkChan(itemData);
|
|
||||||
|
|
||||||
if (chan.length) {
|
|
||||||
chan.trigger("click");
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("input", {
|
|
||||||
target: $("#chat").data("id"),
|
|
||||||
text: "/query " + itemData,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
kick(itemData) {
|
|
||||||
socket.emit("input", {
|
|
||||||
target: $("#chat").data("id"),
|
|
||||||
text: "/kick " + itemData,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
contextMenuActions.execute = (name, ...args) => contextMenuActions[name] && contextMenuActions[name](...args);
|
contextMenuFactory.addContextMenuItem({
|
||||||
|
check: (target) => target.hasClass("chan"),
|
||||||
contextMenu.on("click", ".context-menu-item", function() {
|
className: "close",
|
||||||
const $this = $(this);
|
displayName: getCloseDisplay,
|
||||||
const itemData = $this.data("data");
|
data: (target) => target.data("target"),
|
||||||
const contextAction = $this.data("action");
|
callback: (itemData) => closeChan($(`.networks .chan[data-target="${itemData}"]`)),
|
||||||
contextMenuActions.execute(contextAction, itemData);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($(document.body).hasClass("public") && (window.location.hash === "#connect" || window.location.hash === "")) {
|
if ($(document.body).hasClass("public") && (window.location.hash === "#connect" || window.location.hash === "")) {
|
||||||
|
Loading…
Reference in New Issue
Block a user