合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
BIN
web_mobile/static/src/img/app_store.png
Normal file
BIN
web_mobile/static/src/img/app_store.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
web_mobile/static/src/img/google_play.png
Normal file
BIN
web_mobile/static/src/img/google_play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
48
web_mobile/static/src/js/core/control_panel.js
Normal file
48
web_mobile/static/src/js/core/control_panel.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { device } from "web.config";
|
||||
import * as LegacyControlPanel from "web.ControlPanel";
|
||||
import { useBackButton } from "web_mobile.hooks";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
|
||||
|
||||
if (device.isMobile) {
|
||||
patch(LegacyControlPanel.prototype, "web_mobile", {
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
useBackButton(this._onBackButton.bind(this), () => this.state.showMobileSearch);
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* close mobile search on back-button
|
||||
* @private
|
||||
*/
|
||||
_onBackButton() {
|
||||
this._resetSearchState();
|
||||
},
|
||||
});
|
||||
|
||||
patch(ControlPanel.prototype, "web_mobile", {
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
useBackButton(this._onBackButton.bind(this), () => this.state.showMobileSearch);
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* close mobile search on back-button
|
||||
* @private
|
||||
*/
|
||||
_onBackButton() {
|
||||
this.resetSearchState();
|
||||
},
|
||||
});
|
||||
}
|
||||
79
web_mobile/static/src/js/core/dialog.js
Normal file
79
web_mobile/static/src/js/core/dialog.js
Normal file
@@ -0,0 +1,79 @@
|
||||
odoo.define("web_mobile.Dialog", function (require) {
|
||||
"use strict";
|
||||
|
||||
var Dialog = require("web.Dialog");
|
||||
var mobileMixins = require("web_mobile.mixins");
|
||||
|
||||
Dialog.include(
|
||||
_.extend({}, mobileMixins.BackButtonEventMixin, {
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close the current dialog on 'backbutton' event.
|
||||
*
|
||||
* @private
|
||||
* @override
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onBackButton: function () {
|
||||
this.close();
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
odoo.define("web_mobile.OwlDialog", function (require) {
|
||||
"use strict";
|
||||
|
||||
const OwlDialog = require("web.OwlDialog");
|
||||
const { useBackButton } = require("web_mobile.hooks");
|
||||
const { patch } = require("web.utils");
|
||||
|
||||
patch(OwlDialog.prototype, "web_mobile", {
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
useBackButton(this._onBackButton.bind(this));
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close dialog on back-button
|
||||
* @private
|
||||
*/
|
||||
_onBackButton() {
|
||||
this._close();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
odoo.define("web_mobile.Popover", function (require) {
|
||||
"use strict";
|
||||
|
||||
const Popover = require("web.Popover");
|
||||
const { useBackButton } = require("web_mobile.hooks");
|
||||
const { patch } = require("web.utils");
|
||||
|
||||
patch(Popover.prototype, "web_mobile", {
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
useBackButton(this._onBackButton.bind(this), () => this.state.displayed);
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close popover on back-button
|
||||
* @private
|
||||
*/
|
||||
_onBackButton() {
|
||||
this._close();
|
||||
},
|
||||
});
|
||||
});
|
||||
145
web_mobile/static/src/js/core/mixins.js
Normal file
145
web_mobile/static/src/js/core/mixins.js
Normal file
@@ -0,0 +1,145 @@
|
||||
odoo.define('web_mobile.mixins', function (require) {
|
||||
"use strict";
|
||||
|
||||
const session = require('web.session');
|
||||
const mobile = require('web_mobile.core');
|
||||
|
||||
/**
|
||||
* Mixin to setup lifecycle methods and allow to use 'backbutton' events sent
|
||||
* from the native application.
|
||||
*
|
||||
* @mixin
|
||||
* @name BackButtonEventMixin
|
||||
*
|
||||
*/
|
||||
var BackButtonEventMixin = {
|
||||
/**
|
||||
* Register event listener for 'backbutton' event when attached to the DOM
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
mobile.backButtonManager.addListener(this, this._onBackButton);
|
||||
},
|
||||
/**
|
||||
* Unregister event listener for 'backbutton' event when detached from the DOM
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
mobile.backButtonManager.removeListener(this, this._onBackButton);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev 'backbutton' type event
|
||||
*/
|
||||
_onBackButton: function () {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mixin to hook into the controller record's saving method and
|
||||
* trigger the update of the user's account details on the mobile app.
|
||||
*
|
||||
* @mixin
|
||||
* @name UpdateDeviceAccountControllerMixin
|
||||
*
|
||||
*/
|
||||
const UpdateDeviceAccountControllerMixin = {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async save() {
|
||||
const changedFields = await this._super(...arguments);
|
||||
await session.updateAccountOnMobileDevice();
|
||||
return changedFields;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger the update of the user's account details on the mobile app as soon as
|
||||
* the session is correctly initialized.
|
||||
*/
|
||||
session.is_bound.then(() => session.updateAccountOnMobileDevice());
|
||||
|
||||
return {
|
||||
BackButtonEventMixin: BackButtonEventMixin,
|
||||
UpdateDeviceAccountControllerMixin,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
odoo.define('web_mobile.hooks', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { backButtonManager } = require('web_mobile.core');
|
||||
|
||||
const { onMounted, onPatched, onWillUnmount, useComponent } = owl;
|
||||
|
||||
/**
|
||||
* This hook provides support for executing code when the back button is pressed
|
||||
* on the mobile application of Odoo. This actually replaces the default back
|
||||
* button behavior so this feature should only be enabled when it is actually
|
||||
* useful.
|
||||
*
|
||||
* The feature is either enabled on mount or, using the `shouldEnable` function
|
||||
* argument as condition, when the component is patched. In both cases,
|
||||
* the feature is automatically disabled on unmount.
|
||||
*
|
||||
* @param {function} func the function to execute when the back button is
|
||||
* pressed. The function is called with the custom event as param.
|
||||
* @param {function} [shouldEnable] the function to execute when the DOM is
|
||||
* patched to check if the backbutton should be enabled or disabled ;
|
||||
* if undefined will be enabled on mount and disabled on unmount.
|
||||
*/
|
||||
function useBackButton(func, shouldEnable) {
|
||||
const component = useComponent();
|
||||
let isEnabled = false;
|
||||
|
||||
/**
|
||||
* Enables the func listener, overriding default back button behavior.
|
||||
*/
|
||||
function enable() {
|
||||
backButtonManager.addListener(component, func);
|
||||
isEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the func listener, restoring the default back button behavior if
|
||||
* no other listeners are present.
|
||||
*/
|
||||
function disable() {
|
||||
backButtonManager.removeListener(component);
|
||||
isEnabled = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (shouldEnable && !shouldEnable()) {
|
||||
return;
|
||||
}
|
||||
enable();
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
if (!shouldEnable) {
|
||||
return;
|
||||
}
|
||||
const shouldBeEnabled = shouldEnable();
|
||||
if (shouldBeEnabled && !isEnabled) {
|
||||
enable();
|
||||
} else if (!shouldBeEnabled && isEnabled) {
|
||||
disable();
|
||||
}
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (isEnabled) {
|
||||
disable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
useBackButton,
|
||||
};
|
||||
});
|
||||
18
web_mobile/static/src/js/core/network/download.js
Normal file
18
web_mobile/static/src/js/core/network/download.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import mobile from "web_mobile.core";
|
||||
import { download } from "@web/core/network/download";
|
||||
|
||||
const _download = download._download;
|
||||
|
||||
download._download = async function (options) {
|
||||
if (mobile.methods.downloadFile) {
|
||||
if (odoo.csrf_token) {
|
||||
options.csrf_token = odoo.csrf_token;
|
||||
}
|
||||
mobile.methods.downloadFile(options);
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return _download.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
89
web_mobile/static/src/js/core/session.js
Normal file
89
web_mobile/static/src/js/core/session.js
Normal file
@@ -0,0 +1,89 @@
|
||||
odoo.define('web_mobile.Session', function (require) {
|
||||
"use strict";
|
||||
|
||||
const core = require('web.core');
|
||||
const Session = require('web.Session');
|
||||
|
||||
const mobile = require('web_mobile.core');
|
||||
|
||||
const DEFAULT_AVATAR_SIZE = 128;
|
||||
|
||||
/*
|
||||
Android webview not supporting post download and odoo is using post method to download
|
||||
so here override get_file of session and passed all data to native mobile downloader
|
||||
ISSUE: https://code.google.com/p/android/issues/detail?id=1780
|
||||
*/
|
||||
|
||||
Session.include({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get_file: function (options) {
|
||||
if (mobile.methods.downloadFile) {
|
||||
if (core.csrf_token) {
|
||||
options.csrf_token = core.csrf_token;
|
||||
}
|
||||
mobile.methods.downloadFile(options);
|
||||
// There is no need to wait downloadFile because we delegate this to
|
||||
// Download Manager Service where error handling will be handled correclty.
|
||||
// On our side, we do not want to block the UI and consider the request
|
||||
// as success.
|
||||
if (options.success) { options.success(); }
|
||||
if (options.complete) { options.complete(); }
|
||||
return true;
|
||||
} else {
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user's account details on the mobile app
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async updateAccountOnMobileDevice() {
|
||||
if (!mobile.methods.updateAccount) {
|
||||
return;
|
||||
}
|
||||
const base64Avatar = await this.fetchAvatar();
|
||||
return mobile.methods.updateAccount({
|
||||
avatar: base64Avatar.substring(base64Avatar.indexOf(',') + 1),
|
||||
name: this.name,
|
||||
username: this.username,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch current user's avatar as PNG image
|
||||
*
|
||||
* @returns {Promise} resolved with the dataURL, or rejected if the file is
|
||||
* empty or if an error occurs.
|
||||
*/
|
||||
fetchAvatar() {
|
||||
const url = this.url('/web/image', {
|
||||
model: 'res.users',
|
||||
field: 'image_medium',
|
||||
id: this.uid,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = DEFAULT_AVATAR_SIZE;
|
||||
canvas.height = DEFAULT_AVATAR_SIZE;
|
||||
const context = canvas.getContext('2d');
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => {
|
||||
context.drawImage(image, 0, 0, DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
});
|
||||
image.addEventListener('error', reject);
|
||||
image.src = url;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
14
web_mobile/static/src/js/crash_manager.js
Normal file
14
web_mobile/static/src/js/crash_manager.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import mobile from "web_mobile.core";
|
||||
|
||||
function mobileErrorHandler(env, error, originalError) {
|
||||
if (mobile.methods.crashManager) {
|
||||
error.originalError = originalError;
|
||||
mobile.methods.crashManager(error);
|
||||
}
|
||||
}
|
||||
registry
|
||||
.category("error_handlers")
|
||||
.add("web_mobile.errorHandler", mobileErrorHandler, { sequence: 3 });
|
||||
26
web_mobile/static/src/js/mobile_service.js
Normal file
26
web_mobile/static/src/js/mobile_service.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import mobile from "web_mobile.core";
|
||||
import { shortcutItem, switchAccountItem } from "./user_menu_items";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
const userMenuRegistry = registry.category("user_menuitems");
|
||||
|
||||
const mobileService = {
|
||||
start() {
|
||||
if (mobile.methods.addHomeShortcut) {
|
||||
userMenuRegistry.add("web_mobile.shortcut", shortcutItem);
|
||||
}
|
||||
|
||||
if (mobile.methods.switchAccount) {
|
||||
// remove "Log Out" and "My Odoo.com Account"
|
||||
userMenuRegistry.remove('log_out');
|
||||
userMenuRegistry.remove('odoo_account');
|
||||
|
||||
userMenuRegistry.add("web_mobile.switch", switchAccountItem);
|
||||
}
|
||||
},
|
||||
};
|
||||
serviceRegistry.add("mobile", mobileService);
|
||||
158
web_mobile/static/src/js/services/core.js
Normal file
158
web_mobile/static/src/js/services/core.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/* global OdooDeviceUtility */
|
||||
odoo.define('web_mobile.core', function () {
|
||||
"use strict";
|
||||
|
||||
var available = typeof OdooDeviceUtility !== 'undefined';
|
||||
var DeviceUtility;
|
||||
var deferreds = {};
|
||||
var methods = {};
|
||||
|
||||
if (available){
|
||||
DeviceUtility = OdooDeviceUtility;
|
||||
delete window.OdooDeviceUtility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for invoking native methods which called from JavaScript
|
||||
*
|
||||
* @param {String} name name of action want to perform in mobile
|
||||
* @param {Object} args extra arguments for mobile
|
||||
*
|
||||
* @returns Promise Object
|
||||
*/
|
||||
function native_invoke(name, args) {
|
||||
if(_.isUndefined(args)){
|
||||
args = {};
|
||||
}
|
||||
var id = _.uniqueId();
|
||||
args = JSON.stringify(args);
|
||||
DeviceUtility.execute(name, args, id);
|
||||
return new Promise(function (resolve, reject) {
|
||||
deferreds[id] = {
|
||||
successCallback: resolve,
|
||||
errorCallback: reject
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages deferred callback from initiate from native mobile
|
||||
*
|
||||
* @param {String} id callback id
|
||||
* @param {Object} result
|
||||
*/
|
||||
window.odoo.native_notify = function (id, result) {
|
||||
if (deferreds.hasOwnProperty(id)) {
|
||||
if (result.success) {
|
||||
deferreds[id].successCallback(result);
|
||||
} else {
|
||||
deferreds[id].errorCallback(result);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var plugins = available ? JSON.parse(DeviceUtility.list_plugins()) : [];
|
||||
_.each(plugins, function (plugin) {
|
||||
methods[plugin.name] = function (args) {
|
||||
return native_invoke(plugin.action, args);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Use to notify an uri hash change on native devices (ios / android)
|
||||
*/
|
||||
if (methods.hashChange) {
|
||||
var currentHash;
|
||||
$(window).bind('hashchange', function (event) {
|
||||
var hash = event.getState();
|
||||
if (!_.isEqual(currentHash, hash)) {
|
||||
methods.hashChange(hash);
|
||||
}
|
||||
currentHash = hash;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to the registration of a listener to the backbutton event
|
||||
*/
|
||||
class BackButtonListenerError extends Error {}
|
||||
|
||||
/**
|
||||
* By using the back button feature the default back button behavior from the
|
||||
* app is actually overridden so it is important to keep count to restore the
|
||||
* default when no custom listener are remaining.
|
||||
*/
|
||||
class BackButtonManager {
|
||||
|
||||
constructor() {
|
||||
this._listeners = new Map();
|
||||
this._onGlobalBackButton = this._onGlobalBackButton.bind(this);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enables the func listener, overriding default back button behavior.
|
||||
*
|
||||
* @param {Widget|Component} listener
|
||||
* @param {function} func
|
||||
* @throws {BackButtonListenerError} if the listener has already been registered
|
||||
*/
|
||||
addListener(listener, func) {
|
||||
if (!methods.overrideBackButton) {
|
||||
return;
|
||||
}
|
||||
if (this._listeners.has(listener)) {
|
||||
throw new BackButtonListenerError("This listener was already registered.");
|
||||
}
|
||||
this._listeners.set(listener, func);
|
||||
if (this._listeners.size === 1) {
|
||||
document.addEventListener('backbutton', this._onGlobalBackButton);
|
||||
methods.overrideBackButton({ enabled: true });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Disables the func listener, restoring the default back button behavior if
|
||||
* no other listeners are present.
|
||||
*
|
||||
* @param {Widget|Component} listener
|
||||
* @throws {BackButtonListenerError} if the listener has already been unregistered
|
||||
*/
|
||||
removeListener(listener) {
|
||||
if (!methods.overrideBackButton) {
|
||||
return;
|
||||
}
|
||||
if (!this._listeners.has(listener)) {
|
||||
throw new BackButtonListenerError("This listener has already been unregistered.");
|
||||
}
|
||||
this._listeners.delete(listener);
|
||||
if (this._listeners.size === 0) {
|
||||
document.removeEventListener('backbutton', this._onGlobalBackButton);
|
||||
methods.overrideBackButton({ enabled: false });
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
_onGlobalBackButton() {
|
||||
const [listener, func] = [...this._listeners].pop();
|
||||
if (listener) {
|
||||
func.apply(listener, arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const backButtonManager = new BackButtonManager();
|
||||
|
||||
return {
|
||||
BackButtonManager,
|
||||
BackButtonListenerError,
|
||||
backButtonManager,
|
||||
methods,
|
||||
};
|
||||
|
||||
});
|
||||
38
web_mobile/static/src/js/user_menu_items.js
Normal file
38
web_mobile/static/src/js/user_menu_items.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import mobile from "web_mobile.core";
|
||||
|
||||
export function shortcutItem(env) {
|
||||
return {
|
||||
type: "item",
|
||||
id: "web_mobile.shortcut",
|
||||
description: env._t("Add to Home Screen"),
|
||||
callback: () => {
|
||||
const { hash } = env.services.router.current;
|
||||
if (hash.menu_id) {
|
||||
const menu = env.services.menu.getMenu(hash.menu_id);
|
||||
const base64Icon = menu && menu.webIconData;
|
||||
mobile.methods.addHomeShortcut({
|
||||
title: document.title,
|
||||
shortcut_url: document.URL,
|
||||
web_icon: base64Icon.substring(base64Icon.indexOf(',') + 1),
|
||||
});
|
||||
} else {
|
||||
env.services.notification.add(env._t("No shortcut for Home Menu"));
|
||||
}
|
||||
},
|
||||
sequence: 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function switchAccountItem(env) {
|
||||
return {
|
||||
type: "item",
|
||||
id: "web_mobile.switch",
|
||||
description: env._t("Switch/Add Account"),
|
||||
callback: () => {
|
||||
mobile.methods.switchAccount();
|
||||
},
|
||||
sequence: 100,
|
||||
};
|
||||
}
|
||||
23
web_mobile/static/src/views/user_preferences_form_view.js
Normal file
23
web_mobile/static/src/views/user_preferences_form_view.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { UpdateDeviceAccountControllerMixin } from "web_mobile.mixins";
|
||||
import { Record, RelationalModel } from "@web/views/basic_relational_model";
|
||||
|
||||
export class ResUsersPreferenceRecord extends Record {}
|
||||
export class ResUsersPreferenceModel extends RelationalModel {}
|
||||
ResUsersPreferenceModel.Record = ResUsersPreferenceRecord;
|
||||
|
||||
patch(
|
||||
ResUsersPreferenceRecord.prototype,
|
||||
"res_users_controller_mobile_mixin",
|
||||
UpdateDeviceAccountControllerMixin
|
||||
);
|
||||
|
||||
registry.category("views").add("res_users_preferences_form", {
|
||||
...formView,
|
||||
Model: ResUsersPreferenceModel,
|
||||
});
|
||||
Reference in New Issue
Block a user