mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
fix: fix dev command not loading assets and scripts + fix assement averages
This commit is contained in:
Vendored
+16
@@ -10,6 +10,22 @@ declare module "*?inlineWorker" {
|
||||
export default value;
|
||||
}
|
||||
|
||||
/** CRXJS dynamic content / main-world script path (relative to extension root). */
|
||||
declare module "*?script" {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*?script&iife" {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*?script&module" {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*.png?base64" {
|
||||
const value: string;
|
||||
export default value;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
|
||||
jest.mock("webextension-polyfill", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
runtime: {
|
||||
getURL: (path: string) => `chrome-extension://testid/${path}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { extensionPageScriptUrl } from "./extensionPageScriptUrl";
|
||||
|
||||
describe("extensionPageScriptUrl", () => {
|
||||
it("prefixes chrome.runtime.getURL and strips a leading slash", () => {
|
||||
expect(extensionPageScriptUrl("assets/pageState.js")).toBe(
|
||||
"chrome-extension://testid/assets/pageState.js",
|
||||
);
|
||||
expect(extensionPageScriptUrl("/assets/pageState.js")).toBe(
|
||||
"chrome-extension://testid/assets/pageState.js",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
/**
|
||||
* Resolve a CRXJS `?script` / `?script&iife` import to a web-accessible
|
||||
* chrome-extension:// URL. Works in both `vite build` and `vite dev`
|
||||
* (plain `?url` imports 404 in CRXJS serve because they are not packaged).
|
||||
*/
|
||||
export function extensionPageScriptUrl(scriptPath: string): string {
|
||||
return browser.runtime.getURL(scriptPath.replace(/^\/+/, ""));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText";
|
||||
|
||||
describe("extractWeightFromCoversheetText", () => {
|
||||
it("matches Weight: N", () => {
|
||||
expect(extractWeightFromCoversheetText("Due date ... Weight: 20 Subject")).toBe(
|
||||
"20",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches Weighting: N%", () => {
|
||||
expect(
|
||||
extractWeightFromCoversheetText("Assessment Weighting: 12.5% of semester"),
|
||||
).toBe("12.5");
|
||||
});
|
||||
|
||||
it("matches Assessment weight: N", () => {
|
||||
expect(
|
||||
extractWeightFromCoversheetText("Assessment weight: 15\nCriteria"),
|
||||
).toBe("15");
|
||||
});
|
||||
|
||||
it("matches Weight of N%", () => {
|
||||
expect(extractWeightFromCoversheetText("This task has a weight of 10%")).toBe(
|
||||
"10",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no weighting is present", () => {
|
||||
expect(extractWeightFromCoversheetText("No marks available yet")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Pull a numeric weighting from coversheet / report PDF text. */
|
||||
export function extractWeightFromCoversheetText(text: string): string | null {
|
||||
const patterns = [
|
||||
/weightings?\s*:\s*(\d+(?:\.\d+)?)\s*%?/i,
|
||||
/weight\s*:\s*(\d+(?:\.\d+)?)\s*%?/i,
|
||||
/assessment\s+weight(?:ing)?\s*:\s*(\d+(?:\.\d+)?)\s*%?/i,
|
||||
/weight(?:ing)?\s+(?:of\s+)?(\d+(?:\.\d+)?)\s*%/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
getPdfjsPageContextUrls,
|
||||
} from "@/lib/pdfjsExtension.ts";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText";
|
||||
|
||||
export { extractWeightFromCoversheetText };
|
||||
|
||||
ensurePdfjsWorker();
|
||||
|
||||
@@ -552,135 +555,67 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const escapedUrl = escJsSingleQuoted(url);
|
||||
|
||||
// Import the legacy build in page context so it can set
|
||||
// globalThis.pdfjsLib, then parse the coversheet PDF.
|
||||
script.textContent = `
|
||||
(function() {
|
||||
const requestId = '${requestId}';
|
||||
const pageOrigin = '${escapedOrigin}';
|
||||
const url = '${escapedUrl}';
|
||||
const pdfLibSrc = '${pdfLibInj}';
|
||||
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||
const requestId = '${requestId}';
|
||||
const pageOrigin = '${escapedOrigin}';
|
||||
const url = '${escapedUrl}';
|
||||
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||
|
||||
if (window.pdfjsLib) {
|
||||
extractPDF();
|
||||
function postResult(payload) {
|
||||
window.postMessage({ type: requestId, ...payload }, pageOrigin);
|
||||
}
|
||||
|
||||
try {
|
||||
await import('${pdfLibInj}');
|
||||
const pdfjsLib = globalThis.pdfjsLib;
|
||||
if (!pdfjsLib?.getDocument) {
|
||||
postResult({ success: false, error: 'pdfjsLib missing after import' });
|
||||
} else {
|
||||
const pdfjsScript = document.createElement('script');
|
||||
pdfjsScript.src = pdfLibSrc;
|
||||
pdfjsScript.type = 'module';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||
|
||||
pdfjsScript.onload = function() {
|
||||
extractPDF();
|
||||
};
|
||||
pdfjsScript.onerror = function() {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Failed to load pdfjs library'
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
document.head.appendChild(pdfjsScript);
|
||||
}
|
||||
|
||||
function extractPDF() {
|
||||
try {
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.withCredentials = true;
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status !== 200) {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
|
||||
}, pageOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = xhr.response;
|
||||
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
||||
throw new Error('PDF response is empty');
|
||||
}
|
||||
|
||||
window.pdfjsLib.getDocument({
|
||||
data: arrayBuffer,
|
||||
useSystemFonts: true,
|
||||
verbosity: 0,
|
||||
useWorkerFetch: false,
|
||||
isEvalSupported: false
|
||||
}).promise
|
||||
.then(pdf => {
|
||||
const pagePromises = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
pagePromises.push(
|
||||
pdf.getPage(i).then(page => {
|
||||
return page.getTextContent().then(content => {
|
||||
return content.items.map(item => item.str).join(' ');
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.all(pagePromises);
|
||||
})
|
||||
.then(pages => {
|
||||
const text = pages.join('\\n');
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: true,
|
||||
text: text
|
||||
}, pageOrigin);
|
||||
})
|
||||
.catch(error => {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'PDF parsing error: ' + (error.message || String(error))
|
||||
}, pageOrigin);
|
||||
});
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'ArrayBuffer error: ' + (error.message || String(error))
|
||||
}, pageOrigin);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Network error fetching PDF'
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
xhr.ontimeout = function() {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Timeout fetching PDF'
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
xhr.timeout = 30000;
|
||||
xhr.send();
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Setup error: ' + (error.message || String(error))
|
||||
}, pageOrigin);
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
||||
throw new Error('PDF response is empty');
|
||||
}
|
||||
|
||||
const pdf = await pdfjsLib.getDocument({
|
||||
data: arrayBuffer,
|
||||
useSystemFonts: true,
|
||||
verbosity: 0,
|
||||
useWorkerFetch: false,
|
||||
isEvalSupported: false,
|
||||
}).promise;
|
||||
|
||||
const pages = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
pages.push(content.items.map((item) => item.str).join(' '));
|
||||
}
|
||||
|
||||
postResult({ success: true, text: pages.join('\\n') });
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
postResult({
|
||||
success: false,
|
||||
error: 'PDF extraction error: ' + (error?.message || String(error)),
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
@@ -723,6 +658,9 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
const pdf = await pdfjs.getDocument({
|
||||
data: arrayBuffer,
|
||||
useSystemFonts: true,
|
||||
verbosity: 0,
|
||||
useWorkerFetch: false,
|
||||
isEvalSupported: false,
|
||||
}).promise;
|
||||
|
||||
let text = "";
|
||||
@@ -740,6 +678,85 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function randomStudentPdfFileName(): string {
|
||||
// Matches SEQTA Learn coversheet tokens, e.g. "mrr158ct.pdf".
|
||||
return `${Math.random().toString(36).slice(2, 10)}.pdf`;
|
||||
}
|
||||
|
||||
async function requestStudentAssessmentPdf(params: {
|
||||
assessmentID: string | number;
|
||||
metaclassID: string | number;
|
||||
studentID: string | number;
|
||||
}): Promise<string> {
|
||||
const fileName = randomStudentPdfFileName();
|
||||
|
||||
const printResponse = await fetch(
|
||||
`${location.origin}/seqta/student/print/assessment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
id: Number(params.assessmentID),
|
||||
metaclass: Number(params.metaclassID),
|
||||
student: Number(params.studentID),
|
||||
fileName,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!printResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await printResponse.json()) as {
|
||||
payload?: { file?: string };
|
||||
status?: string | number;
|
||||
};
|
||||
|
||||
const resolved = data.payload?.file;
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`Print assessment response missing payload.file (status=${String(data.status)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function getStudentAssessmentReportUrl(fileName: string): string {
|
||||
const params = new URLSearchParams({
|
||||
type: "generated_report",
|
||||
file: fileName,
|
||||
});
|
||||
return `${location.origin}/seqta/student/load/file?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function extractPDFTextWithRetry(
|
||||
url: string,
|
||||
attempts = 3,
|
||||
delayMs = 1500,
|
||||
): Promise<string> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
return await extractPDFText(url);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const retryable =
|
||||
message.includes("404") ||
|
||||
message.includes("empty") ||
|
||||
message.includes("Failed to fetch PDF");
|
||||
if (!retryable || attempt === attempts - 1) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function handleWeightings(mark: any, api: any) {
|
||||
const assessmentID = assessmentIdKey(mark);
|
||||
const metaclassID = mark.metaclassID;
|
||||
@@ -749,13 +766,25 @@ async function handleWeightings(mark: any, api: any) {
|
||||
| WeightingEntry
|
||||
| undefined;
|
||||
|
||||
const isFresh =
|
||||
// Skip only when we already have a real numeric weight for this fingerprint.
|
||||
// "N/A" / "processing" / in-flight refreshing must not permanently block retries.
|
||||
const hasNumericWeight =
|
||||
existing &&
|
||||
existing.weight !== "processing" &&
|
||||
existing.fingerprint === fingerprint &&
|
||||
existing.pluginVersion === WEIGHTING_SCHEMA_VERSION;
|
||||
existing.weight !== "N/A" &&
|
||||
!Number.isNaN(parseFloat(existing.weight));
|
||||
|
||||
if (isFresh) return;
|
||||
const inFlightSameFingerprint =
|
||||
existing &&
|
||||
existing.fingerprint === fingerprint &&
|
||||
(existing.weight === "processing" || existing.refreshing);
|
||||
|
||||
const isFresh =
|
||||
Boolean(hasNumericWeight) &&
|
||||
existing?.fingerprint === fingerprint &&
|
||||
existing?.pluginVersion === WEIGHTING_SCHEMA_VERSION;
|
||||
|
||||
if (isFresh || inFlightSameFingerprint) return;
|
||||
|
||||
// If we have a previous usable value, keep showing it while we refetch
|
||||
// by marking the entry as refreshing instead of wiping it. We claim the
|
||||
@@ -763,7 +792,7 @@ async function handleWeightings(mark: any, api: any) {
|
||||
// pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate
|
||||
// refetch for the same id while this one is still in flight.
|
||||
const placeholder: WeightingEntry =
|
||||
existing && existing.weight !== "processing"
|
||||
existing && hasNumericWeight
|
||||
? {
|
||||
...existing,
|
||||
fingerprint,
|
||||
@@ -807,34 +836,13 @@ async function handleWeightings(mark: any, api: any) {
|
||||
const userInfo = await getUserInfo();
|
||||
const userID = userInfo.id;
|
||||
|
||||
const filename =
|
||||
"BetterSEQTA-" +
|
||||
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
||||
|
||||
const printResponse = await fetch(
|
||||
`${location.origin}/seqta/student/print/assessment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
fileName: filename,
|
||||
id: assessmentID,
|
||||
metaclass: metaclassID,
|
||||
student: userID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!printResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const reportFile = await requestStudentAssessmentPdf({
|
||||
assessmentID,
|
||||
metaclassID,
|
||||
studentID: userID,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
||||
pdfUrl = getStudentAssessmentReportUrl(reportFile);
|
||||
}
|
||||
|
||||
if (pdfUrl.startsWith("blob:")) {
|
||||
@@ -843,32 +851,37 @@ async function handleWeightings(mark: any, api: any) {
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await extractPDFText(pdfUrl);
|
||||
text = await extractPDFTextWithRetry(pdfUrl);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
isFirefox &&
|
||||
(error?.message?.includes("blob") ||
|
||||
error?.message?.includes("Security") ||
|
||||
error?.message?.includes("CSP"))
|
||||
error?.message?.includes("CSP") ||
|
||||
error?.message?.includes("empty"))
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
text = await extractPDFText(pdfUrl);
|
||||
text = await extractPDFTextWithRetry(pdfUrl, 2, 2000);
|
||||
} else {
|
||||
throw new Error(`PDF extraction failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
|
||||
const weight = extractWeightFromCoversheetText(text);
|
||||
|
||||
api.storage.weightings = {
|
||||
...api.storage.weightings,
|
||||
[assessmentID]: {
|
||||
weight: match ? match[1] : "N/A",
|
||||
weight: weight ?? "N/A",
|
||||
fingerprint,
|
||||
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[BetterSEQTA+] Weighting fetch failed for assessment ${assessmentID}:`,
|
||||
error,
|
||||
);
|
||||
api.storage.weightings = {
|
||||
...api.storage.weightings,
|
||||
[assessmentID]: {
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ import browser from "webextension-polyfill";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
// UI and theme management
|
||||
import pageState from "@/pageState.js?url";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import pageState from "@/pageState.js?script&iife";
|
||||
import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl";
|
||||
import { installSeqtaMenuColourPatch } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
||||
import { installThemeImagePagePatch } from "@/seqta/utils/patchThemeImagesPageContext";
|
||||
|
||||
@@ -40,6 +40,6 @@ export async function main() {
|
||||
|
||||
function injectPageState() {
|
||||
const mainScript = document.createElement("script");
|
||||
mainScript.src = resolveExtensionAssetUrl(pageState);
|
||||
mainScript.src = extensionPageScriptUrl(pageState);
|
||||
document.head.appendChild(mainScript);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* (injected script) plus Coloris / overlay cleanup in the content script.
|
||||
*/
|
||||
|
||||
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?script&iife";
|
||||
import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl";
|
||||
import { verboseInfo } from "@/utils/verboseLog";
|
||||
|
||||
const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader";
|
||||
@@ -128,7 +128,7 @@ export function installSeqtaMenuColourPatch(): void {
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = PAGE_PATCH_LOADER_ID;
|
||||
script.src = resolveExtensionAssetUrl(patchScript);
|
||||
script.src = extensionPageScriptUrl(patchScript);
|
||||
script.addEventListener("load", () => script.remove());
|
||||
(document.documentElement || document.head).appendChild(script);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Bridge theme CSS and decorative images into PAGE JavaScript context.
|
||||
*/
|
||||
|
||||
import patchScript from "@/seqta/utils/themeImagePagePatch.js?url";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import patchScript from "@/seqta/utils/themeImagePagePatch.js?script&iife";
|
||||
import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl";
|
||||
import { blobToBase64Data } from "@/plugins/built-in/themes/themeImageUrl";
|
||||
|
||||
const PAGE_PATCH_LOADER_ID = "bsplus-theme-image-page-patch-loader";
|
||||
@@ -23,7 +23,7 @@ export function installThemeImagePagePatch(): void {
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = PAGE_PATCH_LOADER_ID;
|
||||
script.src = resolveExtensionAssetUrl(patchScript);
|
||||
script.src = extensionPageScriptUrl(patchScript);
|
||||
script.addEventListener("load", () => script.remove());
|
||||
(document.documentElement || document.head).appendChild(script);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user