Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Includes 30 custom nodes committed directly, 7 Civitai-exclusive loras stored via Git LFS, and a setup script that installs all dependencies and downloads HuggingFace-hosted models on vast.ai. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import { ComfyDialog, $el } from "../../../../scripts/ui.js";
|
||||
|
||||
import { restart_from_here } from "./prompt.js";
|
||||
import { hud, FlowState } from "./state.js";
|
||||
import { send_cancel, send_message, send_onstart, skip_next_restart_message } from "./messaging.js";
|
||||
import { display_preview_images, additionalDrawBackground, click_is_in_image } from "./preview.js";
|
||||
import {$t} from "../common/i18n.js";
|
||||
|
||||
|
||||
class chooserImageDialog extends ComfyDialog {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.node = null
|
||||
this.select_index = []
|
||||
this.dialog_div = null
|
||||
}
|
||||
|
||||
show(image,node){
|
||||
this.select_index = []
|
||||
this.node = node
|
||||
|
||||
const images_div = image.map((img, index) => {
|
||||
const imgEl = $el('img', {
|
||||
src: img.src,
|
||||
onclick: _ => {
|
||||
if(this.select_index.includes(index)){
|
||||
this.select_index = this.select_index.filter(i => i !== index)
|
||||
imgEl.classList.remove('selected')
|
||||
} else {
|
||||
this.select_index.push(index)
|
||||
imgEl.classList.add('selected')
|
||||
}
|
||||
if (node.selected.has(index)) node.selected.delete(index);
|
||||
else node.selected.add(index);
|
||||
}
|
||||
})
|
||||
return imgEl
|
||||
})
|
||||
super.show($el('div.easyuse-chooser-dialog',[
|
||||
$el('h5.easyuse-chooser-dialog-title', $t('Choose images to continue')),
|
||||
$el('div.easyuse-chooser-dialog-images',images_div)
|
||||
]))
|
||||
}
|
||||
createButtons() {
|
||||
const btns = super.createButtons();
|
||||
btns[0].onclick = _ => {
|
||||
if (FlowState.running()) { send_cancel();}
|
||||
super.close()
|
||||
}
|
||||
btns.unshift($el('button', {
|
||||
type: 'button',
|
||||
textContent: $t('Choose Selected Images'),
|
||||
onclick: _ => {
|
||||
if (FlowState.paused()) {
|
||||
send_message(this.node.id, [...this.node.selected, -1, ...this.node.anti_selected]);
|
||||
}
|
||||
if (FlowState.idle()) {
|
||||
skip_next_restart_message();
|
||||
restart_from_here(this.node.id).then(() => { send_message(this.node.id, [...this.node.selected, -1, ...this.node.anti_selected]); });
|
||||
}
|
||||
super.close()
|
||||
}
|
||||
}))
|
||||
return btns
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function progressButtonPressed() {
|
||||
const node = app.graph._nodes_by_id[this.node_id];
|
||||
if (node) {
|
||||
const selected = [...node.selected]
|
||||
if(selected?.length>0){
|
||||
node.setProperty('values',selected)
|
||||
}
|
||||
if (FlowState.paused()) {
|
||||
send_message(node.id, [...node.selected, -1, ...node.anti_selected]);
|
||||
}
|
||||
if (FlowState.idle()) {
|
||||
skip_next_restart_message();
|
||||
restart_from_here(node.id).then(() => { send_message(node.id, [...node.selected, -1, ...node.anti_selected]); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelButtonPressed() {
|
||||
|
||||
if (FlowState.running()) { send_cancel();}
|
||||
}
|
||||
|
||||
function enable_disabling(button) {
|
||||
Object.defineProperty(button, 'clicked', {
|
||||
get : function() { return this._clicked; },
|
||||
set : function(v) { this._clicked = (v && this.name!=''); }
|
||||
})
|
||||
}
|
||||
|
||||
function disable_serialize(widget) {
|
||||
if (!widget.options) widget.options = { };
|
||||
widget.options.serialize = false;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name:'comfy.easyuse.imageChooser',
|
||||
init() {
|
||||
window.addEventListener("beforeunload", send_cancel, true);
|
||||
},
|
||||
setup(app) {
|
||||
|
||||
const draw = LGraphCanvas.prototype.draw;
|
||||
LGraphCanvas.prototype.draw = function() {
|
||||
if (hud.update()) {
|
||||
app.graph._nodes.forEach((node)=> { if (node.update) { node.update(); } })
|
||||
}
|
||||
draw.apply(this,arguments);
|
||||
}
|
||||
|
||||
|
||||
function easyuseImageChooser(event) {
|
||||
const {node,image,isKSampler} = display_preview_images(event);
|
||||
if(isKSampler) {
|
||||
const dialog = new chooserImageDialog();
|
||||
dialog.show(image,node)
|
||||
}
|
||||
}
|
||||
api.addEventListener("easyuse-image-choose", easyuseImageChooser);
|
||||
|
||||
/*
|
||||
If a run is interrupted, send a cancel message (unless we're doing the cancelling, to avoid infinite loop)
|
||||
*/
|
||||
const original_api_interrupt = api.interrupt;
|
||||
api.interrupt = function () {
|
||||
if (FlowState.paused() && !FlowState.cancelling) send_cancel();
|
||||
original_api_interrupt.apply(this, arguments);
|
||||
}
|
||||
|
||||
/*
|
||||
At the start of execution
|
||||
*/
|
||||
function on_execution_start() {
|
||||
if (send_onstart()) {
|
||||
app.graph._nodes.forEach((node)=> {
|
||||
if (node.selected || node.anti_selected) {
|
||||
node.selected.clear();
|
||||
node.anti_selected.clear();
|
||||
node.update();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
api.addEventListener("execution_start", on_execution_start);
|
||||
},
|
||||
|
||||
async nodeCreated(node, app) {
|
||||
|
||||
if(node.comfyClass == 'easy imageChooser'){
|
||||
node.setProperty('values',[])
|
||||
|
||||
/* A property defining the top of the image when there is just one */
|
||||
if(node?.imageIndex === undefined){
|
||||
Object.defineProperty(node, 'imageIndex', {
|
||||
get : function() { return null; },
|
||||
set: function (v) {node.overIndex= v},
|
||||
})
|
||||
}
|
||||
if(node?.imagey === undefined){
|
||||
Object.defineProperty(node, 'imagey', {
|
||||
get : function() { return null; },
|
||||
set: function (v) {return node.widgets[node.widgets.length-1].last_y+LiteGraph.NODE_WIDGET_HEIGHT;},
|
||||
})
|
||||
}
|
||||
|
||||
/* Capture clicks */
|
||||
const org_onMouseDown = node.onMouseDown;
|
||||
node.onMouseDown = function( e, pos, canvas ) {
|
||||
if (e.isPrimary) {
|
||||
const i = click_is_in_image(node, pos);
|
||||
if (i>=0) { this.imageClicked(i); }
|
||||
}
|
||||
return (org_onMouseDown && org_onMouseDown.apply(this, arguments));
|
||||
}
|
||||
|
||||
node.send_button_widget = node.addWidget("button", "", "", progressButtonPressed);
|
||||
node.cancel_button_widget = node.addWidget("button", "", "", cancelButtonPressed);
|
||||
enable_disabling(node.cancel_button_widget);
|
||||
enable_disabling(node.send_button_widget);
|
||||
disable_serialize(node.cancel_button_widget);
|
||||
disable_serialize(node.send_button_widget);
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if(nodeData?.name == 'easy imageChooser'){
|
||||
|
||||
const onDrawBackground = nodeType.prototype.onDrawBackground;
|
||||
nodeType.prototype.onDrawBackground = function(ctx) {
|
||||
onDrawBackground.apply(this, arguments);
|
||||
additionalDrawBackground(this, ctx);
|
||||
}
|
||||
|
||||
nodeType.prototype.imageClicked = function (imageIndex) {
|
||||
if (nodeType?.comfyClass==="easy imageChooser") {
|
||||
if (this.selected.has(imageIndex)) this.selected.delete(imageIndex);
|
||||
else this.selected.add(imageIndex);
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
const update = nodeType.prototype.update;
|
||||
nodeType.prototype.update = function() {
|
||||
if (update) update.apply(this,arguments);
|
||||
if (this.send_button_widget) {
|
||||
this.send_button_widget.node_id = this.id;
|
||||
const selection = ( this.selected ? this.selected.size : 0 ) + ( this.anti_selected ? this.anti_selected.size : 0 )
|
||||
const maxlength = this.imgs?.length || 0;
|
||||
if (FlowState.paused_here(this.id) && selection>0) {
|
||||
this.send_button_widget.name = (selection>1) ? "Progress selected (" + selection + '/' + maxlength +")" : "Progress selected image";
|
||||
} else if (selection>0) {
|
||||
this.send_button_widget.name = (selection>1) ? "Progress selected (" + selection + '/' + maxlength +")" : "Progress selected image as restart";
|
||||
}
|
||||
else {
|
||||
this.send_button_widget.name = "";
|
||||
}
|
||||
}
|
||||
if (this.cancel_button_widget) {
|
||||
const isRunning = FlowState.running()
|
||||
this.cancel_button_widget.name = isRunning ? "Cancel current run" : "";
|
||||
}
|
||||
this.setDirtyCanvas(true,true);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import { FlowState } from "./state.js";
|
||||
|
||||
function send_message_from_pausing_node(message) {
|
||||
const id = app.runningNodeId;
|
||||
send_message(id, message);
|
||||
}
|
||||
|
||||
function send_message(id, message) {
|
||||
const body = new FormData();
|
||||
body.append('message',message);
|
||||
body.append('id', id);
|
||||
api.fetchApi("/easyuse/image_chooser_message", { method: "POST", body, });
|
||||
}
|
||||
|
||||
function send_cancel() {
|
||||
send_message(-1,'__cancel__');
|
||||
FlowState.cancelling = true;
|
||||
api.interrupt();
|
||||
FlowState.cancelling = false;
|
||||
}
|
||||
|
||||
var skip_next = 0;
|
||||
function skip_next_restart_message() { skip_next += 1; }
|
||||
function send_onstart() {
|
||||
if (skip_next>0) {
|
||||
skip_next -= 1;
|
||||
return false;
|
||||
}
|
||||
send_message(-1,'__start__');
|
||||
return true;
|
||||
}
|
||||
|
||||
export { send_message_from_pausing_node, send_cancel, send_message, send_onstart, skip_next_restart_message }
|
||||
@@ -0,0 +1,90 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
const kSampler = ['easy kSampler', 'easy kSamplerTiled', 'easy fullkSampler']
|
||||
|
||||
function display_preview_images(event) {
|
||||
const node = app.graph._nodes_by_id[event.detail.id];
|
||||
if (node) {
|
||||
node.selected = new Set();
|
||||
node.anti_selected = new Set();
|
||||
const image = showImages(node, event.detail.urls);
|
||||
return {node,image,isKSampler:kSampler.includes(node.type)}
|
||||
} else {
|
||||
console.log(`Image Chooser Preview - failed to find ${event.detail.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function showImages(node, urls) {
|
||||
node.imgs = [];
|
||||
urls.forEach((u)=> {
|
||||
const img = new Image();
|
||||
node.imgs.push(img);
|
||||
img.onload = () => { app.graph.setDirtyCanvas(true); };
|
||||
img.src = `/view?filename=${encodeURIComponent(u.filename)}&type=temp&subfolder=${app.getPreviewFormatParam()}`
|
||||
})
|
||||
node.setSizeForImage?.();
|
||||
return node.imgs
|
||||
}
|
||||
|
||||
function drawRect(node, s, ctx) {
|
||||
const padding = 1;
|
||||
var rect;
|
||||
if (node.imageRects) {
|
||||
rect = node.imageRects[s];
|
||||
} else {
|
||||
const y = node.imagey;
|
||||
rect = [padding,y+padding,node.size[0]-2*padding,node.size[1]-y-2*padding];
|
||||
}
|
||||
ctx.strokeRect(rect[0]+padding, rect[1]+padding, rect[2]-padding*2, rect[3]-padding*2);
|
||||
}
|
||||
|
||||
function additionalDrawBackground(node, ctx) {
|
||||
if (!node.imgs) return;
|
||||
if (node.imageRects) {
|
||||
for (let i = 0; i < node.imgs.length; i++) {
|
||||
// delete underlying image
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(...node.imageRects[i])
|
||||
// draw the new one
|
||||
const img = node.imgs[i];
|
||||
const cellWidth = node.imageRects[i][2];
|
||||
const cellHeight = node.imageRects[i][3];
|
||||
|
||||
let wratio = cellWidth/img.width;
|
||||
let hratio = cellHeight/img.height;
|
||||
var ratio = Math.min(wratio, hratio);
|
||||
|
||||
let imgHeight = ratio * img.height;
|
||||
let imgWidth = ratio * img.width;
|
||||
|
||||
const imgX = node.imageRects[i][0] + (cellWidth - imgWidth)/2;
|
||||
const imgY = node.imageRects[i][1] + (cellHeight - imgHeight)/2;
|
||||
const cell_padding = 2;
|
||||
ctx.drawImage(img, imgX+cell_padding, imgY+cell_padding, imgWidth-cell_padding*2, imgHeight-cell_padding*2);
|
||||
|
||||
}
|
||||
}
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = "green";
|
||||
node?.selected?.forEach((s) => { drawRect(node,s, ctx) })
|
||||
ctx.strokeStyle = "#F88";
|
||||
node?.anti_selected?.forEach((s) => { drawRect(node,s, ctx) })
|
||||
}
|
||||
|
||||
function click_is_in_image(node, pos) {
|
||||
if (node.imgs?.length>1) {
|
||||
for (var i = 0; i<node.imageRects.length; i++) {
|
||||
const dx = pos[0] - node.imageRects[i][0];
|
||||
const dy = pos[1] - node.imageRects[i][1];
|
||||
if ( dx > 0 && dx < node.imageRects[i][2] &&
|
||||
dy > 0 && dy < node.imageRects[i][3] ) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
} else if (node.imgs?.length==1) {
|
||||
if (pos[1]>node.imagey) return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export { display_preview_images, additionalDrawBackground, click_is_in_image }
|
||||
@@ -0,0 +1,114 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
function links_with(p, node_id, down, up) {
|
||||
const links_with = [];
|
||||
p.workflow.links.forEach((l) => {
|
||||
if (down && l[1]===node_id && !links_with.includes(l[3])) links_with.push(l[3])
|
||||
if (up && l[3]===node_id && !links_with.includes(l[1])) links_with.push(l[1])
|
||||
});
|
||||
return links_with;
|
||||
}
|
||||
|
||||
function _all_v_nodes(p, here_id) {
|
||||
/*
|
||||
Make a list of all downstream nodes.
|
||||
*/
|
||||
const downstream = [];
|
||||
const to_process = [here_id]
|
||||
while(to_process.length>0) {
|
||||
const id = to_process.pop();
|
||||
downstream.push(id);
|
||||
to_process.push(
|
||||
...links_with(p,id,true,false).filter((nid)=>{
|
||||
return !(downstream.includes(nid) || to_process.includes(nid))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
Now all upstream nodes from any of the downstream nodes (except us).
|
||||
Put us on the result list so we don't flow up through us
|
||||
*/
|
||||
to_process.push(...downstream.filter((n)=>{ return n!=here_id}));
|
||||
const back_upstream = [here_id];
|
||||
while(to_process.length>0) {
|
||||
const id = to_process.pop();
|
||||
back_upstream.push(id);
|
||||
to_process.push(
|
||||
...links_with(p,id,false,true).filter((nid)=>{
|
||||
return !(back_upstream.includes(nid) || to_process.includes(nid))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const keep = [];
|
||||
keep.push(...downstream);
|
||||
keep.push(...back_upstream.filter((n)=>{return !keep.includes(n)}));
|
||||
|
||||
console.log(`Nodes to keep: ${keep}`);
|
||||
return keep;
|
||||
}
|
||||
|
||||
async function all_v_nodes(here_id) {
|
||||
const p = structuredClone(await app.graphToPrompt());
|
||||
const all_nodes = [];
|
||||
p.workflow.nodes.forEach((node)=>{all_nodes.push(node.id)})
|
||||
p.workflow.links = p.workflow.links.filter((l)=>{ return (all_nodes.includes(l[1]) && all_nodes.includes(l[3]))} )
|
||||
return _all_v_nodes(p,here_id);
|
||||
}
|
||||
|
||||
async function restart_from_here(here_id, go_down_to_chooser=false) {
|
||||
const p = structuredClone(await app.graphToPrompt());
|
||||
/*
|
||||
Make a list of all nodes, and filter out links that are no longer valid
|
||||
*/
|
||||
const all_nodes = [];
|
||||
p.workflow.nodes.forEach((node)=>{all_nodes.push(node.id)})
|
||||
p.workflow.links = p.workflow.links.filter((l)=>{ return (all_nodes.includes(l[1]) && all_nodes.includes(l[3]))} )
|
||||
|
||||
/* Move downstream to a chooser */
|
||||
if (go_down_to_chooser) {
|
||||
while (!app.graph._nodes_by_id[here_id].isChooser) {
|
||||
here_id = links_with(p, here_id, true, false)[0];
|
||||
}
|
||||
}
|
||||
|
||||
const keep = _all_v_nodes(p, here_id);
|
||||
|
||||
/*
|
||||
Filter p.workflow.nodes and p.workflow.links
|
||||
*/
|
||||
p.workflow.nodes = p.workflow.nodes.filter((node) => {
|
||||
if (node.id===here_id) node.inputs.forEach((i)=>{i.link=null}) // remove our upstream links
|
||||
return (keep.includes(node.id)) // only keep keepers
|
||||
})
|
||||
p.workflow.links = p.workflow.links.filter((l) => {return (keep.includes(l[1]) && keep.includes(l[3]))})
|
||||
|
||||
/*
|
||||
Filter the p.output object to only include nodes we're keeping
|
||||
*/
|
||||
const new_output = {}
|
||||
for (const [key, value] of Object.entries(p.output)) {
|
||||
if (keep.includes(parseInt(key))) new_output[key] = value;
|
||||
}
|
||||
/*
|
||||
Filter the p.output entry for the start node to remove any list (ie link) inputs
|
||||
*/
|
||||
const new_inputs = {};
|
||||
for (const [key, value] of Object.entries(new_output[here_id.toString()].inputs)) {
|
||||
if (!Array.isArray(value)) new_inputs[key] = value;
|
||||
}
|
||||
new_output[here_id.toString()].inputs = new_inputs;
|
||||
|
||||
p.output = new_output;
|
||||
|
||||
// temporarily hijack graph_to_prompt with a version that restores the old one but returns this prompt
|
||||
const gtp_was = app.graphToPrompt;
|
||||
app.graphToPrompt = () => {
|
||||
app.graphToPrompt = gtp_was;
|
||||
return p;
|
||||
}
|
||||
app.queuePrompt(0);
|
||||
}
|
||||
|
||||
export { restart_from_here, all_v_nodes }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
|
||||
class HUD {
|
||||
constructor() {
|
||||
this.current_node_id = undefined;
|
||||
this.class_of_current_node = null;
|
||||
this.current_node_is_chooser = false;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (app.runningNodeId==this.current_node_id) return false;
|
||||
|
||||
this.current_node_id = app.runningNodeId;
|
||||
|
||||
if (this.current_node_id) {
|
||||
this.class_of_current_node = app.graph?._nodes_by_id[app.runningNodeId.toString()]?.comfyClass;
|
||||
this.current_node_is_chooser = this.class_of_current_node === "easy imageChooser"
|
||||
} else {
|
||||
this.class_of_current_node = undefined;
|
||||
this.current_node_is_chooser = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const hud = new HUD();
|
||||
|
||||
|
||||
class FlowState {
|
||||
constructor(){}
|
||||
static idle() {
|
||||
return (!app.runningNodeId);
|
||||
}
|
||||
static paused() {
|
||||
return true;
|
||||
}
|
||||
static paused_here(node_id) {
|
||||
return (FlowState.paused() && FlowState.here(node_id))
|
||||
}
|
||||
static running() {
|
||||
return (!FlowState.idle());
|
||||
}
|
||||
static here(node_id) {
|
||||
return (app.runningNodeId==node_id);
|
||||
}
|
||||
static state() {
|
||||
if (FlowState.paused()) return "Paused";
|
||||
if (FlowState.running()) return "Running";
|
||||
return "Idle";
|
||||
}
|
||||
static cancelling = false;
|
||||
}
|
||||
|
||||
export { hud, FlowState}
|
||||
Reference in New Issue
Block a user