Add: broadcast messages with reasons
This commit is contained in:
parent
e7ef4948cd
commit
b5107b78a5
|
@ -9,16 +9,12 @@ global usercss styleManager db msg
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
|
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
|
||||||
|
|
||||||
// getStyles,
|
|
||||||
getSectionsByUrl: styleManager.getSectionsByUrl,
|
getSectionsByUrl: styleManager.getSectionsByUrl,
|
||||||
getSectionsById: styleManager.getSectionsById,
|
getSectionsById: styleManager.getSectionsById,
|
||||||
getStylesInfo: styleManager.getStylesInfo,
|
getStylesInfo: styleManager.getStylesInfo,
|
||||||
toggleStyle: styleManager.toggleStyle,
|
toggleStyle: styleManager.toggleStyle,
|
||||||
deleteStyle: styleManager.deleteStyle,
|
deleteStyle: styleManager.deleteStyle,
|
||||||
getStylesInfoByUrl: styleManager.getStylesInfoByUrl,
|
getStylesInfoByUrl: styleManager.getStylesInfoByUrl,
|
||||||
// saveStyle,
|
|
||||||
// deleteStyle,
|
|
||||||
|
|
||||||
getStyleFromDB: id =>
|
getStyleFromDB: id =>
|
||||||
db.exec('get', id).then(event => event.target.result),
|
db.exec('get', id).then(event => event.target.result),
|
||||||
|
|
|
@ -13,10 +13,7 @@ const styleManager = (() => {
|
||||||
const compiledExclusion = createCache();
|
const compiledExclusion = createCache();
|
||||||
const BAD_MATCHER = {test: () => false};
|
const BAD_MATCHER = {test: () => false};
|
||||||
|
|
||||||
// FIXME: do we have to prepare `styles` map for all methods?
|
|
||||||
return ensurePrepared({
|
return ensurePrepared({
|
||||||
// styles,
|
|
||||||
// cachedStyleForUrl,
|
|
||||||
getStylesInfo,
|
getStylesInfo,
|
||||||
getSectionsByUrl,
|
getSectionsByUrl,
|
||||||
installStyle,
|
installStyle,
|
||||||
|
@ -26,9 +23,7 @@ const styleManager = (() => {
|
||||||
toggleStyle,
|
toggleStyle,
|
||||||
getAllStyles, // used by import-export
|
getAllStyles, // used by import-export
|
||||||
getStylesInfoByUrl, // used by popup
|
getStylesInfoByUrl, // used by popup
|
||||||
countStyles,
|
countStyles
|
||||||
// TODO: get all styles API?
|
|
||||||
// TODO: get style by ID?
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getAllStyles() {
|
function getAllStyles() {
|
||||||
|
@ -37,25 +32,28 @@ const styleManager = (() => {
|
||||||
|
|
||||||
function toggleStyle(id, enabled) {
|
function toggleStyle(id, enabled) {
|
||||||
const style = styles.get(id);
|
const style = styles.get(id);
|
||||||
const newData = Object.assign({}, style.data, {enabled});
|
const data = Object.assign({}, style.data, {enabled});
|
||||||
return saveStyle(newData)
|
return saveStyle(data)
|
||||||
.then(newData => {
|
.then(newData => {
|
||||||
style.data = newData;
|
style.data = newData;
|
||||||
for (const url of style.appliesTo) {
|
for (const url of style.appliesTo) {
|
||||||
const cache = cachedStyleForUrl.get(url);
|
const cache = cachedStyleForUrl.get(url);
|
||||||
if (cache) {
|
if (cache) {
|
||||||
cache[newData.id].enabled = newData.enabled;
|
cache.sections[newData.id].enabled = newData.enabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = {
|
const message = {
|
||||||
method: 'styleUpdated',
|
method: 'styleUpdated',
|
||||||
|
reason: 'toggle',
|
||||||
codeIsUpdated: false,
|
codeIsUpdated: false,
|
||||||
style: {id, enabled}
|
style: {id, enabled}
|
||||||
};
|
};
|
||||||
if ([...style.appliesTo].every(isExtensionUrl)) {
|
if ([...style.appliesTo].every(isExtensionUrl)) {
|
||||||
return msg.broadcastExtension(message, 'both');
|
return msg.broadcastExtension(message, 'both');
|
||||||
}
|
}
|
||||||
return msg.broadcast(message, tab => style.appliesTo.has(tab.url));
|
// FIXME: this won't work with iframes
|
||||||
|
// return msg.broadcast(message, tab => style.appliesTo.has(tab.url));
|
||||||
|
return msg.broadcast(message);
|
||||||
})
|
})
|
||||||
.then(() => id);
|
.then(() => id);
|
||||||
}
|
}
|
||||||
|
@ -69,7 +67,7 @@ const styleManager = (() => {
|
||||||
return [getStyleWithNoCode(styles.get(filter.id).data)];
|
return [getStyleWithNoCode(styles.get(filter.id).data)];
|
||||||
}
|
}
|
||||||
return [...styles.values()]
|
return [...styles.values()]
|
||||||
.filter(s => !filter || filterMatchStyle(filter, s.data))
|
.filter(s => !filter || filterMatch(filter, s.data))
|
||||||
.map(s => getStyleWithNoCode(s.data));
|
.map(s => getStyleWithNoCode(s.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,75 +79,19 @@ const styleManager = (() => {
|
||||||
return styles.has(filter.id) ? 1 : 0;
|
return styles.has(filter.id) ? 1 : 0;
|
||||||
}
|
}
|
||||||
return [...styles.values()]
|
return [...styles.values()]
|
||||||
.filter(s => filterMatchStyle(filter, s.data))
|
.filter(s => filterMatch(filter, s.data))
|
||||||
.length;
|
.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterMatchStyle(filter, style) {
|
function filterMatch(filter, target) {
|
||||||
for (const key of Object.keys(filter)) {
|
for (const key of Object.keys(filter)) {
|
||||||
if (filter[key] !== style[key]) {
|
if (filter[key] !== target[key]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function editSave(data) {
|
|
||||||
data = Object.assign({}, styles.get(data.id).data, data);
|
|
||||||
return saveStyle(data)
|
|
||||||
.then(newData =>
|
|
||||||
broadcastStyleUpdated(newData)
|
|
||||||
.then(() => newData)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStyleExclusions(id, exclusions) {
|
|
||||||
const data = Object.assign({}, styles.get(id), {exclusions});
|
|
||||||
return saveStyle(data)
|
|
||||||
.then(newData =>
|
|
||||||
broadcastStyleUpdated(newData)
|
|
||||||
.then(() => newData)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensurePrepared(methods) {
|
|
||||||
for (const [name, fn] in Object.entries(methods)) {
|
|
||||||
methods[name] = (...args) =>
|
|
||||||
preparing.then(() => fn(...args));
|
|
||||||
}
|
|
||||||
return methods;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteStyle(id) {
|
|
||||||
const style = styles.get(id);
|
|
||||||
return db.exec('delete', id)
|
|
||||||
.then(() => {
|
|
||||||
for (const url of style.appliesTo) {
|
|
||||||
const cache = cachedStyleForUrl.get(url);
|
|
||||||
if (cache) {
|
|
||||||
delete cache[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
styles.delete(id);
|
|
||||||
return msg.broadcast({
|
|
||||||
method: 'styleDeleted',
|
|
||||||
style: {id}
|
|
||||||
}, tab => style.appliesTo.has(tab.url));
|
|
||||||
})
|
|
||||||
.then(() => id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNewStyle() {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
updateUrl: null,
|
|
||||||
md5Url: null,
|
|
||||||
url: null,
|
|
||||||
originalMd5: null,
|
|
||||||
installDate: Date.now()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function installStyle(data) {
|
function installStyle(data) {
|
||||||
const style = styles.get(data.id);
|
const style = styles.get(data.id);
|
||||||
if (!style) {
|
if (!style) {
|
||||||
|
@ -164,99 +106,130 @@ const styleManager = (() => {
|
||||||
return saveStyle(data);
|
return saveStyle(data);
|
||||||
})
|
})
|
||||||
.then(newData =>
|
.then(newData =>
|
||||||
broadcastStyleUpdated(newData)
|
broadcastStyleUpdated(newData, style ? 'update' : 'install')
|
||||||
.then(() => newData)
|
.then(() => newData)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastStyleUpdated(newData) {
|
function editSave(data) {
|
||||||
const style = styles.get(newData.id);
|
const style = styles.get(data.id);
|
||||||
|
if (style) {
|
||||||
|
data = Object.assign({}, style.data, data);
|
||||||
|
} else {
|
||||||
|
data = Object.assign(createNewStyle(), data);
|
||||||
|
}
|
||||||
|
return saveStyle(data)
|
||||||
|
.then(newData =>
|
||||||
|
broadcastStyleUpdated(newData, 'editSave')
|
||||||
|
.then(() => newData)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStyleExclusions(id, exclusions) {
|
||||||
|
const data = Object.assign({}, styles.get(id), {exclusions});
|
||||||
|
return saveStyle(data)
|
||||||
|
.then(newData =>
|
||||||
|
broadcastStyleUpdated(newData, 'exclusions')
|
||||||
|
.then(() => newData)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteStyle(id) {
|
||||||
|
const style = styles.get(id);
|
||||||
|
return db.exec('delete', id)
|
||||||
|
.then(() => {
|
||||||
|
for (const url of style.appliesTo) {
|
||||||
|
const cache = cachedStyleForUrl.get(url);
|
||||||
|
if (cache) {
|
||||||
|
delete cache.sections[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
styles.delete(id);
|
||||||
|
return msg.broadcast({
|
||||||
|
method: 'styleDeleted',
|
||||||
|
style: {id}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePrepared(methods) {
|
||||||
|
for (const [name, fn] in Object.entries(methods)) {
|
||||||
|
methods[name] = (...args) =>
|
||||||
|
preparing.then(() => fn(...args));
|
||||||
|
}
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewStyle() {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
updateUrl: null,
|
||||||
|
md5Url: null,
|
||||||
|
url: null,
|
||||||
|
originalMd5: null,
|
||||||
|
installDate: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastStyleUpdated(data, reason) {
|
||||||
|
const style = styles.get(data.id);
|
||||||
if (!style) {
|
if (!style) {
|
||||||
// new style
|
// new style
|
||||||
const appliesTo = new Set();
|
const appliesTo = new Set();
|
||||||
styles.set(newData.id, {
|
styles.set(data.id, {
|
||||||
appliesTo,
|
appliesTo,
|
||||||
data: newData
|
data
|
||||||
|
});
|
||||||
|
for (const cache of cachedStyleForUrl.values()) {
|
||||||
|
cache.maybeMatch.add(data.id);
|
||||||
|
}
|
||||||
|
return msg.broadcast({
|
||||||
|
method: 'styleAdded',
|
||||||
|
style: {id: data.id, enabled: data.enabled},
|
||||||
|
reason
|
||||||
});
|
});
|
||||||
return Promise.all([
|
|
||||||
msg.broadcastExtension({method: 'styleAdded', style: getStyleWithNoCode(newData)}),
|
|
||||||
msg.broadcastTab(tab => getStyleAddedMessage(tab, newData, appliesTo))
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
const excluded = new Set();
|
const excluded = new Set();
|
||||||
const updated = new Map();
|
const updated = new Set();
|
||||||
for (const url of style.appliesTo) {
|
for (const [url, cache] of cachedStyleForUrl.entries()) {
|
||||||
const code = getAppliedCode(url, newData);
|
if (!style.appliesTo.has(url)) {
|
||||||
const cache = cachedStyleForUrl.get(url);
|
cache.maybeMatch.add(data.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const code = getAppliedCode(url, data);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
excluded.add(url);
|
excluded.add(url);
|
||||||
if (cache) {
|
delete cache.sections[data.id];
|
||||||
delete cache[newData.id];
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
updated.set(url, code);
|
updated.add(url);
|
||||||
if (cache) {
|
cache.sections[data.id] = {
|
||||||
cache[newData.id] = {
|
|
||||||
id: newData.id,
|
|
||||||
enabled: newData.enabled,
|
|
||||||
code
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
style.appliesTo = new Set(updated.keys());
|
|
||||||
return Promise.all([
|
|
||||||
msg.broadcastExtension({method: 'styleUpdated', style: getStyleWithNoCode(newData)}),
|
|
||||||
msg.broadcastTab(tab => {
|
|
||||||
if (excluded.has(tab.url)) {
|
|
||||||
return {
|
|
||||||
method: 'styleDeleted',
|
|
||||||
style: {id: newData.id}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (updated.has(tab.url)) {
|
|
||||||
return {
|
|
||||||
method: 'styleUpdated',
|
|
||||||
style: {id: newData.id, sections: updated.get(tab.url)}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return getStyleAddedMessage(tab, newData, style.appliesTo);
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStyleAddedMessage(tab, data, appliesTo) {
|
|
||||||
const code = getAppliedCode(tab.url, data);
|
|
||||||
if (!code) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cache = cachedStyleForUrl.get(tab.url);
|
|
||||||
if (cache) {
|
|
||||||
cache[data.id] = {
|
|
||||||
id: data.id,
|
id: data.id,
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
code
|
code
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
appliesTo.add(tab.url);
|
}
|
||||||
return {
|
style.data = data;
|
||||||
method: 'styleAdded',
|
style.appliesTo = updated;
|
||||||
|
return msg.broadcast({
|
||||||
|
method: 'styleUpdated',
|
||||||
style: {
|
style: {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
enabled: data.enabled,
|
enabled: data.enabled
|
||||||
sections: code
|
},
|
||||||
}
|
reason
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function importStyle(style) {
|
// function importStyle(style) {
|
||||||
// FIXME: move this to importer
|
// FIXME: move this to importer
|
||||||
// style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
|
// style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
|
||||||
// delete style.styleDigest; // TODO: remove in the future
|
// delete style.styleDigest; // TODO: remove in the future
|
||||||
// if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) {
|
// if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) {
|
||||||
// delete style.originalDigest;
|
// delete style.originalDigest;
|
||||||
// }
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
function saveStyle(style) {
|
function saveStyle(style) {
|
||||||
if (!style.name) {
|
if (!style.name) {
|
||||||
|
@ -280,11 +253,40 @@ const styleManager = (() => {
|
||||||
function getSectionsByUrl(url, filter) {
|
function getSectionsByUrl(url, filter) {
|
||||||
let cache = cachedStyleForUrl.get(url);
|
let cache = cachedStyleForUrl.get(url);
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
cache = {};
|
cache = {
|
||||||
for (const {appliesTo, data} of styles.values()) {
|
sections: {},
|
||||||
|
maybeMatch: new Set()
|
||||||
|
};
|
||||||
|
buildCache(styles.values());
|
||||||
|
cachedStyleForUrl.set(url, cache);
|
||||||
|
} else if (cache.maybeMatch.size) {
|
||||||
|
buildCache(
|
||||||
|
[...cache.maybeMatch]
|
||||||
|
.filter(i => styles.has(i))
|
||||||
|
.map(i => styles.get(i))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// if (filter && filter.id) {
|
||||||
|
// if (!cache.sections[filter.id]) {
|
||||||
|
// return {};
|
||||||
|
// }
|
||||||
|
// return {[filter.id]: cache.sections[filter.id]};
|
||||||
|
// }
|
||||||
|
if (filter) {
|
||||||
|
return Object.values(cache.sections)
|
||||||
|
.filter(s => filterMatch(filter, s))
|
||||||
|
.reduce((o, v) => {
|
||||||
|
o[v.id] = v;
|
||||||
|
return o;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return cache.sections;
|
||||||
|
|
||||||
|
function buildCache(styleList) {
|
||||||
|
for (const {appliesTo, data} of styleList) {
|
||||||
const code = getAppliedCode(url, data);
|
const code = getAppliedCode(url, data);
|
||||||
if (code) {
|
if (code) {
|
||||||
cache[data.id] = {
|
cache.sections[data.id] = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
code
|
code
|
||||||
|
@ -292,20 +294,7 @@ const styleManager = (() => {
|
||||||
appliesTo.add(url);
|
appliesTo.add(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cachedStyleForUrl.set(url, cache);
|
|
||||||
}
|
}
|
||||||
if (filter && filter.id) {
|
|
||||||
return {[filter.id]: cache[filter.id]};
|
|
||||||
}
|
|
||||||
if (filter) {
|
|
||||||
return Object.values(cache)
|
|
||||||
.filter(s => filterMatchStyle(filter, s))
|
|
||||||
.reduce((o, v) => {
|
|
||||||
o[v.id] = v;
|
|
||||||
return o;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
return cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppliedCode(url, data) {
|
function getAppliedCode(url, data) {
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
const pageContextQueue = [];
|
const pageContextQueue = [];
|
||||||
|
|
||||||
// FIXME: styleViaAPI
|
// FIXME: styleViaAPI
|
||||||
|
// FIXME: getStylesFallback?
|
||||||
if (!chrome.app && document instanceof XMLDocument) {
|
if (!chrome.app && document instanceof XMLDocument) {
|
||||||
API.styleViaAPI({action: 'styleApply'});
|
API.styleViaAPI({action: 'styleApply'});
|
||||||
} else {
|
} else {
|
||||||
|
@ -47,15 +48,6 @@
|
||||||
window.addEventListener(chrome.runtime.id, orphanCheck, true);
|
window.addEventListener(chrome.runtime.id, orphanCheck, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// function requestStyles(options, callback = applyStyles) {
|
|
||||||
// FIXME: options?
|
|
||||||
// FIXME: getStylesFallback?
|
|
||||||
// API.getSectionsByUrl(getMatchUrl(), {enabled: true})
|
|
||||||
// .then(Object.values);
|
|
||||||
// .then(buildSections)
|
|
||||||
// .then(callback);
|
|
||||||
// }
|
|
||||||
|
|
||||||
function getMatchUrl() {
|
function getMatchUrl() {
|
||||||
var matchUrl = location.href;
|
var matchUrl = location.href;
|
||||||
if (!matchUrl.match(/^(http|file|chrome|ftp)/)) {
|
if (!matchUrl.match(/^(http|file|chrome|ftp)/)) {
|
||||||
|
@ -97,16 +89,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOnMessage(request) {
|
function applyOnMessage(request) {
|
||||||
// if (request.styles === 'DIY') {
|
|
||||||
// Do-It-Yourself tells our built-in pages to fetch the styles directly
|
|
||||||
// which is faster because IPC messaging JSON-ifies everything internally
|
|
||||||
// requestStyles({}, styles => {
|
|
||||||
// request.styles = styles;
|
|
||||||
// applyOnMessage(request);
|
|
||||||
// });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (!chrome.app && document instanceof XMLDocument && request.method !== 'ping') {
|
if (!chrome.app && document instanceof XMLDocument && request.method !== 'ping') {
|
||||||
request.action = request.method;
|
request.action = request.method;
|
||||||
request.method = null;
|
request.method = null;
|
||||||
|
@ -124,15 +106,17 @@
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'styleUpdated':
|
case 'styleUpdated':
|
||||||
if (!request.codeIsUpdated) {
|
if (request.codeIsUpdated === false) {
|
||||||
applyStyleState(request.style);
|
applyStyleState(request.style);
|
||||||
break;
|
} else if (request.style.enabled) {
|
||||||
}
|
|
||||||
if (request.style.enabled) {
|
|
||||||
removeStyle({id: request.style.id, retire: true});
|
|
||||||
API.getSectionsByUrl(getMatchUrl(), {id: request.style.id})
|
API.getSectionsByUrl(getMatchUrl(), {id: request.style.id})
|
||||||
.then(buildSections)
|
.then(sections => {
|
||||||
.then(applyStyles);
|
if (!sections[request.style.id]) {
|
||||||
|
removeStyle(request.style);
|
||||||
|
} else {
|
||||||
|
applyStyles(buildSections(sections));
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
removeStyle(request.style);
|
removeStyle(request.style);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,8 @@ function createCache(size = 1000) {
|
||||||
delete: delete_,
|
delete: delete_,
|
||||||
clear,
|
clear,
|
||||||
has: id => map.has(id),
|
has: id => map.has(id),
|
||||||
|
entries: () => map.entries(),
|
||||||
|
values: () => map.values(),
|
||||||
get size() {
|
get size() {
|
||||||
return map.size;
|
return map.size;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user