Update: 更新 Web USB
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
goog.loadJs('web', () => {
|
||||
|
||||
goog.require('path');
|
||||
goog.require('BoardId');
|
||||
goog.require('FSWrapper');
|
||||
goog.require('DAPWrapper');
|
||||
goog.require('PartialFlashing');
|
||||
goog.require('ESPTool');
|
||||
goog.require('AdafruitESPTool');
|
||||
goog.require('CryptoJS');
|
||||
@@ -58,6 +62,10 @@ BU.FILMWARE_LAYER = new HTMLTemplate(
|
||||
|
||||
const BAUD = goog.platform() === 'darwin' ? 460800 : 921600;
|
||||
|
||||
if (['BBC micro:bit', 'Mithon CC'].includes(BOARD.boardType)) {
|
||||
FSWrapper.setupFilesystem(path.join(Env.boardDirPath, 'build'));
|
||||
}
|
||||
|
||||
BU.requestPort = async () => {
|
||||
await Serial.requestPort();
|
||||
}
|
||||
@@ -136,85 +144,93 @@ BU.initBurn = () => {
|
||||
}
|
||||
}
|
||||
|
||||
BU.burnByUSB = () => {
|
||||
const portName = 'web-usb';
|
||||
Serial.connect(portName, 115200, async (port) => {
|
||||
if (!port) {
|
||||
return;
|
||||
}
|
||||
let portObj = Serial.portsOperator[portName];
|
||||
const { toolConfig, serialport } = portObj;
|
||||
const prevBaud = toolConfig.baudRates;
|
||||
if (prevBaud !== 115200) {
|
||||
toolConfig.baudRates = 115200;
|
||||
await serialport.setBaudRate(toolConfig.baudRates);
|
||||
}
|
||||
const { web } = SELECTED_BOARD;
|
||||
const { burn } = web;
|
||||
const hexStr = goog.get(path.join(Env.boardDirPath, burn.filePath));
|
||||
const hex2Blob = new Blob([ hexStr ], { type: 'text/plain' });
|
||||
const buffer = await hex2Blob.arrayBuffer();
|
||||
if (!buffer) {
|
||||
layer.msg(Msg.Lang['shell.bin.readFailed'], { time: 1000 });
|
||||
return;
|
||||
}
|
||||
BU.burning = true;
|
||||
BU.uploading = false;
|
||||
const { mainStatusBarTabs } = Mixly;
|
||||
const statusBarTerminal = mainStatusBarTabs.getStatusBarById('output');
|
||||
statusBarTerminal.setValue(Msg.Lang['shell.burning'] + '...\n');
|
||||
mainStatusBarTabs.show();
|
||||
mainStatusBarTabs.changeTo('output');
|
||||
const layerNum = layer.open({
|
||||
type: 1,
|
||||
title: Msg.Lang['shell.burning'] + '...',
|
||||
content: $('#mixly-loader-div'),
|
||||
shade: LayerExt.SHADE_NAV,
|
||||
resize: false,
|
||||
closeBtn: 0,
|
||||
success: function (layero, index) {
|
||||
$(".layui-layer-page").css("z-index","198910151");
|
||||
$("#mixly-loader-btn").hide();
|
||||
let prevPercent = 0;
|
||||
Serial.DAPLink.on(DAPjs.DAPLink.EVENT_PROGRESS, progress => {
|
||||
const nowPercent = Math.floor(progress * 100);
|
||||
if (nowPercent > prevPercent) {
|
||||
prevPercent = nowPercent;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const nowProgressLen = Math.floor(nowPercent / 2);
|
||||
const leftStr = new Array(nowProgressLen).fill('=').join('');
|
||||
const rightStr = (new Array(50 - nowProgressLen).fill('-')).join('');
|
||||
statusBarTerminal.addValue(`[${leftStr}${rightStr}] ${nowPercent}%\n`);
|
||||
});
|
||||
Serial.flash(buffer)
|
||||
.then(() => {
|
||||
layer.close(index);
|
||||
layer.msg(Msg.Lang['shell.burnSucc'], { time: 1000 });
|
||||
statusBarTerminal.addValue(`==${Msg.Lang['shell.burnSucc']}==\n`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
layer.close(index);
|
||||
statusBarTerminal.addValue(`==${Msg.Lang['shell.burnFailed']}==\n`);
|
||||
})
|
||||
.finally(async () => {
|
||||
BU.burning = false;
|
||||
BU.uploading = false;
|
||||
if (toolConfig.baudRates !== prevBaud) {
|
||||
toolConfig.baudRates = prevBaud;
|
||||
await serialport.setBaudRate(prevBaud);
|
||||
}
|
||||
Serial.DAPLink.removeAllListeners(DAPjs.DAPLink.EVENT_PROGRESS);
|
||||
});
|
||||
},
|
||||
end: function () {
|
||||
$("#mixly-loader-btn").css('display', 'inline-block');
|
||||
$('#mixly-loader-div').css('display', 'none');
|
||||
$("#layui-layer-shade" + layerNum).remove();
|
||||
BU.burnByUSB = async () => {
|
||||
const { mainStatusBarTabs } = Mixly;
|
||||
let portName = Serial.getSelectedPortName();
|
||||
if (!portName) {
|
||||
try {
|
||||
await BU.requestPort();
|
||||
portName = Serial.getSelectedPortName();
|
||||
if (!portName) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
Debug.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const statusBarSerial = mainStatusBarTabs.getStatusBarById(portName);
|
||||
if (statusBarSerial) {
|
||||
await statusBarSerial.close();
|
||||
}
|
||||
|
||||
const { web } = SELECTED_BOARD;
|
||||
const { burn } = web;
|
||||
const hexStr = goog.get(path.join(Env.boardDirPath, burn.filePath));
|
||||
const hex2Blob = new Blob([ hexStr ], { type: 'text/plain' });
|
||||
const buffer = await hex2Blob.arrayBuffer();
|
||||
if (!buffer) {
|
||||
layer.msg(Msg.Lang['shell.bin.readFailed'], { time: 1000 });
|
||||
return;
|
||||
}
|
||||
BU.burning = true;
|
||||
BU.uploading = false;
|
||||
const statusBarTerminal = mainStatusBarTabs.getStatusBarById('output');
|
||||
statusBarTerminal.setValue(`${Msg.Lang['shell.burning']}...\n`);
|
||||
mainStatusBarTabs.show();
|
||||
mainStatusBarTabs.changeTo('output');
|
||||
const port = Serial.getPort(portName);
|
||||
const webUSB = new DAPjs.WebUSB(port);
|
||||
const dapLink = new DAPjs.DAPLink(webUSB);
|
||||
try {
|
||||
await dapLink.connect();
|
||||
await dapLink.setSerialBaudrate(115200);
|
||||
} catch (error) {
|
||||
Debug.error(error);
|
||||
return;
|
||||
}
|
||||
let prevPercent = 0;
|
||||
dapLink.on(DAPjs.DAPLink.EVENT_PROGRESS, progress => {
|
||||
const nowPercent = Math.floor(progress * 100);
|
||||
if (nowPercent > prevPercent) {
|
||||
prevPercent = nowPercent;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const nowProgressLen = Math.floor(nowPercent / 2);
|
||||
const leftStr = new Array(nowProgressLen).fill('=').join('');
|
||||
const rightStr = (new Array(50 - nowProgressLen).fill('-')).join('');
|
||||
statusBarTerminal.addValue(`[${leftStr}${rightStr}] ${nowPercent}%\n`);
|
||||
});
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: `${Msg.Lang['shell.burning']}...`,
|
||||
content: $('#mixly-loader-div'),
|
||||
shade: LayerExt.SHADE_NAV,
|
||||
resize: false,
|
||||
closeBtn: 0,
|
||||
success: async (layero, index) => {
|
||||
$('#mixly-loader-btn').hide();
|
||||
try {
|
||||
await dapLink.flash(buffer);
|
||||
layer.close(index);
|
||||
layer.msg(Msg.Lang['shell.burnSucc'], { time: 1000 });
|
||||
statusBarTerminal.addValue(`==${Msg.Lang['shell.burnSucc']}==\n`);
|
||||
} catch (error) {
|
||||
Debug.error(error);
|
||||
layer.close(index);
|
||||
statusBarTerminal.addValue(`==${Msg.Lang['shell.burnFailed']}==\n`);
|
||||
} finally {
|
||||
dapLink.removeAllListeners(DAPjs.DAPLink.EVENT_PROGRESS);
|
||||
await dapLink.disconnect();
|
||||
await webUSB.close();
|
||||
await port.close();
|
||||
}
|
||||
},
|
||||
end: function () {
|
||||
$('#mixly-loader-btn').css('display', 'inline-block');
|
||||
$('#mixly-loader-div').css('display', 'none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -299,7 +315,7 @@ BU.burnWithEsptool = async (binFile, erase) => {
|
||||
compress: true,
|
||||
calculateMD5Hash: (image) => CryptoJS.MD5(CryptoJS.enc.Latin1.parse(image))
|
||||
};
|
||||
const layerNum = layer.open({
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: Msg.Lang['shell.burning'] + '...',
|
||||
content: $('#mixly-loader-div'),
|
||||
@@ -410,7 +426,7 @@ BU.burnWithAdafruitEsptool = async (binFile, erase) => {
|
||||
statusBarTerminal.addValue("Done!\n");
|
||||
BU.burning = true;
|
||||
BU.uploading = false;
|
||||
const layerNum = layer.open({
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: Msg.Lang['shell.burning'] + '...',
|
||||
content: $('#mixly-loader-div'),
|
||||
@@ -525,7 +541,86 @@ BU.initUpload = async () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
BU.uploadWithAmpy(portName);
|
||||
if (['BBC micro:bit', 'Mithon CC'].includes(BOARD.boardType)) {
|
||||
BU.uploadByUSB(portName);
|
||||
} else {
|
||||
BU.uploadWithAmpy(portName);
|
||||
}
|
||||
}
|
||||
|
||||
BU.uploadByUSB = async (portName) => {
|
||||
const { mainStatusBarTabs } = Mixly;
|
||||
if (!portName) {
|
||||
try {
|
||||
await BU.requestPort();
|
||||
portName = Serial.getSelectedPortName();
|
||||
if (!portName) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
Debug.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const statusBarSerial = mainStatusBarTabs.getStatusBarById(portName);
|
||||
if (statusBarSerial) {
|
||||
await statusBarSerial.close();
|
||||
}
|
||||
|
||||
const port = Serial.getPort(portName);
|
||||
const statusBarTerminal = mainStatusBarTabs.getStatusBarById('output');
|
||||
const dapWrapper = new DAPWrapper(port, {
|
||||
event: (data) => {
|
||||
console.log(data);
|
||||
},
|
||||
log: () => {}
|
||||
});
|
||||
const partialFlashing = new PartialFlashing(dapWrapper, {
|
||||
event: (data) => {
|
||||
console.log(data);
|
||||
}
|
||||
});
|
||||
|
||||
BU.burning = false;
|
||||
BU.uploading = true;
|
||||
statusBarTerminal.setValue(Msg.Lang['shell.uploading'] + '...\n');
|
||||
mainStatusBarTabs.show();
|
||||
mainStatusBarTabs.changeTo('output');
|
||||
const mainWorkspace = Workspace.getMain();
|
||||
const editor = mainWorkspace.getEditorsManager().getActive();
|
||||
const code = editor.getCode();
|
||||
FSWrapper.writeFile('main.py', code);
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: Msg.Lang['shell.uploading'] + '...',
|
||||
content: $('#mixly-loader-div'),
|
||||
shade: LayerExt.SHADE_NAV,
|
||||
resize: false,
|
||||
closeBtn: 0,
|
||||
success: async function (layero, index) {
|
||||
try {
|
||||
await partialFlashing.flashAsync(new BoardId(0x9900), FSWrapper, () => {});
|
||||
layer.close(index);
|
||||
layer.msg(Msg.Lang['shell.uploadSucc'], { time: 1000 });
|
||||
statusBarTerminal.addValue(`==${Msg.Lang['shell.uploadSucc']}==\n`);
|
||||
if (!statusBarSerial) {
|
||||
mainStatusBarTabs.add('serial', portName);
|
||||
statusBarSerial = mainStatusBarTabs.getStatusBarById(portName);
|
||||
}
|
||||
statusBarSerial.setValue('');
|
||||
mainStatusBarTabs.changeTo(portName);
|
||||
await statusBarSerial.open();
|
||||
} catch (error) {
|
||||
await dapWrapper.disconnectAsync();
|
||||
layer.close(index);
|
||||
console.error(error);
|
||||
statusBarTerminal.addValue(`${error}\n`);
|
||||
statusBarTerminal.addValue(`==${Msg.Lang['shell.uploadFailed']}==\n`);
|
||||
}
|
||||
BU.burning = false;
|
||||
BU.uploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
BU.uploadWithAmpy = (portName) => {
|
||||
@@ -547,7 +642,7 @@ BU.uploadWithAmpy = (portName) => {
|
||||
useBuffer = true;
|
||||
dataLength = 30;
|
||||
}
|
||||
const layerNum = layer.open({
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: Msg.Lang['shell.uploading'] + '...',
|
||||
content: $('#mixly-loader-div'),
|
||||
|
||||
@@ -119,9 +119,9 @@ class WebHID extends Serial {
|
||||
async #addReadEventListener_() {
|
||||
this.#device_.oninputreport = (event) => {
|
||||
const { data, reportId } = event;
|
||||
const length = Math.min(data.getUint8(0), data.byteLength);
|
||||
const length = Math.min(data.getUint8(0) + 1, data.byteLength);
|
||||
let buffer = [];
|
||||
for (let i = 1; i <= length; i++) {
|
||||
for (let i = 1; i < length; i++) {
|
||||
buffer.push(data.getUint8(i));
|
||||
}
|
||||
this.onBuffer(buffer);
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
goog.loadJs('web', () => {
|
||||
|
||||
goog.require('saveAs');
|
||||
goog.require('Blob');
|
||||
goog.require('Blockly');
|
||||
goog.require('Mixly.MFile');
|
||||
goog.require('Mixly.Config');
|
||||
goog.require('Mixly.MicrobitFs');
|
||||
goog.require('Mixly.LocalStorage');
|
||||
goog.require('Mixly.Web.File');
|
||||
goog.provide('Mixly.Web.Lms');
|
||||
|
||||
const {
|
||||
Web,
|
||||
MFile,
|
||||
Config,
|
||||
MicrobitFs,
|
||||
LocalStorage
|
||||
} = Mixly;
|
||||
|
||||
const { File, Lms } = Web;
|
||||
|
||||
const { BOARD } = Config;
|
||||
|
||||
const DOM_STR = `
|
||||
<li class="layui-nav-item" lay-unselect="">
|
||||
<a href="#" class="icon-upload">保存到教学平台</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
Lms.getUrlParam = function(name) {
|
||||
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)"); // 构造一个含有目标参数的正则表达式对象
|
||||
var r = window.location.search.substr(1).match(reg); // 匹配目标参数
|
||||
if (r != null) return unescape(r[2]); return null; // 返回参数值
|
||||
}
|
||||
|
||||
Lms.save2moodle = function() {
|
||||
var id = Lms.getUrlParam('id');
|
||||
var hash = Lms.getUrlParam('hash');
|
||||
var userid = Lms.getUrlParam('userid');
|
||||
var taskid = Lms.getUrlParam('taskid');
|
||||
if (id == null || hash == null || userid == null) {
|
||||
alert('参数有误,请检查(请从作业进入)');
|
||||
return false;
|
||||
}
|
||||
var data = '';
|
||||
data = MFile.getCode();
|
||||
type = 'py';
|
||||
var xml = Blockly.Xml.workspaceToDom(Mixly.Editor.blockEditor);
|
||||
data = Blockly.Xml.domToText(xml);
|
||||
type = 'xml';
|
||||
$.post('../../post_server_js.php', { unionid: id, hash: hash, userid: userid, content: data, type: type }, function (result) {
|
||||
var json = eval('(' + result + ')');
|
||||
alert(json.result);
|
||||
});
|
||||
}
|
||||
|
||||
Lms.loadfrommoodle = function() {
|
||||
// 当有localStorage缓存时,不从api接口中读取数据,否则api读取后会存在localStorage中,重复显示出来 add by qiang 20180521
|
||||
var xml_str = LocalStorage.get(BOARD.boardType);
|
||||
var pattern = /<xml[\w\W]*?>(.*)<\/xml>/i
|
||||
var code = pattern.exec(xml_str)
|
||||
|
||||
if (code != null && code[1] != '') {
|
||||
console.log(code[1]);
|
||||
console.log('read from localStorage');
|
||||
return false;
|
||||
}
|
||||
var data = '';
|
||||
var type = 'xml';
|
||||
var id = Lms.getUrlParam('id');
|
||||
var hash = Lms.getUrlParam('hash');
|
||||
var userid = Lms.getUrlParam('userid');
|
||||
var taskid = Lms.getUrlParam('taskid');
|
||||
if (id == null || hash == null || userid == null) {
|
||||
// alert('参数有误,请检查');
|
||||
return false;
|
||||
}
|
||||
$.post('../../get_content_microbitpy.php', { unionid: id, hash: hash, userid: userid, content: data }, function (result) {
|
||||
const { blockEditor } = Editor;
|
||||
if (result == '') {
|
||||
return;
|
||||
} else {
|
||||
var count = blockEditor.getAllBlocks().length;
|
||||
if (count) {
|
||||
blockEditor.clear();
|
||||
}
|
||||
type = result.substr(0, 3);
|
||||
data = result.substr(3);
|
||||
}
|
||||
File.parseData(`.${type}`, data);
|
||||
var selectFile = document.getElementById('select_file');
|
||||
if (selectFile != null) {
|
||||
$("#select_file").remove();
|
||||
$("#select_file_wrapper").remove();
|
||||
selectFile = document.getElementById('select_file');
|
||||
}
|
||||
if (selectFile == null) {
|
||||
var selectFileDom = document.createElement('INPUT');
|
||||
selectFileDom.type = 'file';
|
||||
selectFileDom.id = 'select_file';
|
||||
|
||||
var selectFileWrapperDom = document.createElement('DIV');
|
||||
selectFileWrapperDom.id = 'select_file_wrapper';
|
||||
selectFileWrapperDom.style.display = 'none';
|
||||
selectFileWrapperDom.appendChild(selectFileDom);
|
||||
|
||||
document.body.appendChild(selectFileWrapperDom);
|
||||
selectFile = document.getElementById('select_file');
|
||||
}
|
||||
selectFile.click();
|
||||
});
|
||||
}
|
||||
|
||||
Lms.save2hex = function() {
|
||||
const code = MFile.getCode();
|
||||
const output = MicrobitFs.getHex(code);
|
||||
var blob = new Blob([output], { type: 'text/xml' });
|
||||
saveAs(blob, 'blockduino.hex');
|
||||
}
|
||||
|
||||
Lms.changeState = function() {
|
||||
var id = Lms.getUrlParam('id');
|
||||
var hash = Lms.getUrlParam('hash');
|
||||
var userid = Lms.getUrlParam('userid');
|
||||
var taskid = Lms.getUrlParam('taskid');
|
||||
if (id == null || hash == null || userid == null) {
|
||||
return false;
|
||||
}
|
||||
const $dom = $(DOM_STR);
|
||||
$dom.find('a').off().click(() => {
|
||||
Lms.save2moodle();
|
||||
})
|
||||
$('#nav #nav-right-btn-list').append($dom);
|
||||
Lms.loadfrommoodle();
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -91,6 +91,7 @@ class USB extends Serial {
|
||||
});
|
||||
|
||||
navigator?.usb?.addEventListener('disconnect', (event) => {
|
||||
event.device.onclose && event.device.onclose();
|
||||
this.removePort(event.device);
|
||||
this.refreshPorts();
|
||||
});
|
||||
@@ -122,13 +123,29 @@ class USB extends Serial {
|
||||
}
|
||||
|
||||
#addReadEventListener_() {
|
||||
this.#dapLink_.on(DAPjs.DAPLink.EVENT_SERIAL_DATA, data => {
|
||||
const str = data.split('');
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
this.onChar(str[i]);
|
||||
this.#reader_ = this.#startSerialRead_();
|
||||
|
||||
this.#device_.onclose = () => {
|
||||
if (!this.isOpened()) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
this.#dapLink_.startSerialRead(this.#device_);
|
||||
super.close();
|
||||
this.#stringTemp_ = '';
|
||||
this.onClose(1);
|
||||
}
|
||||
}
|
||||
|
||||
async #startSerialRead_(serialDelay = 10, autoConnect = false) {
|
||||
this.#dapLink_.serialPolling = true;
|
||||
|
||||
while (this.#dapLink_.serialPolling) {
|
||||
const data = await this.#dapLink_.serialRead();
|
||||
if (data !== undefined) {
|
||||
const numberArray = Array.prototype.slice.call(new Uint8Array(data));
|
||||
this.onBuffer(numberArray);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, serialDelay));
|
||||
}
|
||||
}
|
||||
|
||||
async open(baud) {
|
||||
@@ -156,9 +173,10 @@ class USB extends Serial {
|
||||
return;
|
||||
}
|
||||
super.close();
|
||||
this.#dapLink_.removeAllListeners(DAPjs.DAPLink.EVENT_SERIAL_DATA);
|
||||
this.#dapLink_.stopSerialRead();
|
||||
await this.#dapLink_.stopSerialRead();
|
||||
if (this.#reader_) {
|
||||
await this.#reader_;
|
||||
}
|
||||
await this.#dapLink_.disconnect();
|
||||
this.#dapLink_ = null;
|
||||
await this.#webUSB_.close();
|
||||
@@ -172,7 +190,7 @@ class USB extends Serial {
|
||||
if (!this.isOpened() || this.getBaudRate() === baud) {
|
||||
return;
|
||||
}
|
||||
await this.setSerialBaudrate(baud);
|
||||
await this.#dapLink_.setSerialBaudrate(baud);
|
||||
await super.setBaudRate(baud);
|
||||
}
|
||||
|
||||
@@ -204,19 +222,24 @@ class USB extends Serial {
|
||||
return this.setDTRAndRTS(this.getDTR(), rts);
|
||||
}
|
||||
|
||||
onChar(char) {
|
||||
super.onChar(char);
|
||||
if (['\r', '\n'].includes(char)) {
|
||||
super.onString(this.#stringTemp_);
|
||||
this.#stringTemp_ = '';
|
||||
} else {
|
||||
this.#stringTemp_ += char;
|
||||
}
|
||||
const buffer = this.encode(char);
|
||||
onBuffer(buffer) {
|
||||
super.onBuffer(buffer);
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
super.onByte(buffer[i]);
|
||||
}
|
||||
const string = this.decodeBuffer(buffer);
|
||||
if (!string) {
|
||||
return;
|
||||
}
|
||||
for (let char of string) {
|
||||
super.onChar(char);
|
||||
if (['\r', '\n'].includes(char)) {
|
||||
super.onString(this.#stringTemp_);
|
||||
this.#stringTemp_ = '';
|
||||
} else {
|
||||
this.#stringTemp_ += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user