mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
refactor(lint): remove the head_leaked_text rule (#3385)
The rule fired on legitimate content and blocked check. A prose CSS comment naming a tag, such as "the <body> rule below sets the base font", was enough: HEAD_CONTENT_PATTERN ends the head at the first <body> in raw source, so the token inside the comment truncated the capture mid-<style>. The unclosed style tag then defeated the strip-ignorable-blocks pass, and the stylesheet's own rules reached the orphan-CSS matcher, which reported a valid nearby rule as the leak. Removed rather than repaired. Across all 643 shipped registry files it fires zero times, so it has never caught anything real here, while producing at least one confirmed false positive that blocked a working cloud render. It is an error, not a warning, so the cost of a false positive is a blocked pipeline. Leaked text of this kind is also visible in the very first preview frame, which is a faster and more reliable signal than a regex over raw source. Takes its seven helpers and eight now-dead patterns with it, plus four orphaned test fixtures. VISIBLE_MARKUP_COMMENT_PATTERN is kept; it belongs to visible_markup_comment. Refs #3384
This commit is contained in:
@@ -1,35 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
function compositionWithHead(headContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithHeadBoundary(boundaryContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
${boundaryContent}
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
|
||||
return `
|
||||
<html>
|
||||
@@ -48,34 +19,6 @@ ${rootContent}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
${prefixContent}
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function templateCompositionWithHead(headContent: string): string {
|
||||
return `
|
||||
<template>
|
||||
<html>
|
||||
<head>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
</body>
|
||||
</html>
|
||||
</template>`;
|
||||
}
|
||||
|
||||
describe("core rules", () => {
|
||||
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
|
||||
const html = `
|
||||
@@ -218,7 +161,7 @@ describe("core rules", () => {
|
||||
// Regression: a CSS comment referencing an SVG tag name (e.g. `/* <g> wrapper */`)
|
||||
// inside a <style> block reads as a real open tag to the flat TAG_PATTERN scan,
|
||||
// manufacturing a phantom root before the real composition root and firing
|
||||
// root_missing_composition_id/root_missing_dimensions/head_leaked_text on an
|
||||
// root_missing_composition_id/root_missing_dimensions on an
|
||||
// otherwise valid sub-composition.
|
||||
const html = `
|
||||
<html><body>
|
||||
@@ -234,7 +177,6 @@ describe("core rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when timeline registry is missing", async () => {
|
||||
@@ -343,167 +285,6 @@ describe("core rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS text is left outside a style block in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</style>
|
||||
/* Decorative Elements */
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("<head>");
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
it("reports error when CSS variables leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
--bg-color: #F5F1E8;
|
||||
--text-color: #212121;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("<head>");
|
||||
expect(finding?.snippet).toContain("body");
|
||||
});
|
||||
|
||||
it("reports error when stray close tags leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
</style>
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</style>");
|
||||
});
|
||||
|
||||
it("reports error when markdown code fences leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
\`\`\`css
|
||||
.particle {
|
||||
color: white;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("```css");
|
||||
});
|
||||
|
||||
it("reports error when CSS at-rules leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
@media (min-width: 800px) {
|
||||
.particle {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("@media");
|
||||
});
|
||||
|
||||
it("does not report leaked text for valid script and style blocks around the head boundary", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script>
|
||||
<template>
|
||||
<style>
|
||||
.template-only { color: red; }
|
||||
</style>
|
||||
</template>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS text leaks before the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(`
|
||||
.orphan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".orphan");
|
||||
});
|
||||
|
||||
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
|
||||
const html = compositionWithImplicitBodyPrefix(`
|
||||
.implicit-body-orphan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".implicit-body-orphan");
|
||||
});
|
||||
|
||||
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(`
|
||||
<style>
|
||||
.pre-root-helper { color: red; }
|
||||
</style>
|
||||
<script>
|
||||
window.__preRootReady = true;
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report CSS-looking educational text inside the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
`
|
||||
<pre>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</pre>
|
||||
`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS block comment syntax leaks into visible markup", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
@@ -585,175 +366,6 @@ body {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when a stray style close tag is left in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</style>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</style>");
|
||||
});
|
||||
|
||||
it("reports error when a stray script close tag is left in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script>
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</script>");
|
||||
});
|
||||
|
||||
it("does not report leaked head text for valid closing tags with trailing whitespace", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style data-parser-error-close>
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script
|
||||
data-parser-error-close>
|
||||
<title>Particle Field</title >
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when markdown code fences leak into the document head", async () => {
|
||||
const withLanguage = compositionWithHead(`
|
||||
\`\`\`css
|
||||
.particle {
|
||||
position: absolute;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const withoutLanguage = compositionWithHead(`
|
||||
\`\`\`
|
||||
.particle {
|
||||
position: absolute;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const withTsxLanguage = compositionWithHead(`
|
||||
\`\`\`tsx
|
||||
export function Particle() {
|
||||
return <div className="particle" />;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const withLanguageResult = await lintHyperframeHtml(withLanguage);
|
||||
const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage);
|
||||
const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage);
|
||||
const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text");
|
||||
const unlabeledFinding = withoutLanguageResult.findings.find(
|
||||
(f) => f.code === "head_leaked_text",
|
||||
);
|
||||
const tsxLanguageFinding = withTsxLanguageResult.findings.find(
|
||||
(f) => f.code === "head_leaked_text",
|
||||
);
|
||||
|
||||
expect(languageFinding).toBeDefined();
|
||||
expect(languageFinding?.snippet).toContain("```css");
|
||||
expect(unlabeledFinding).toBeDefined();
|
||||
expect(unlabeledFinding?.snippet).toContain("```");
|
||||
expect(tsxLanguageFinding).toBeDefined();
|
||||
expect(tsxLanguageFinding?.snippet).toContain("```tsx");
|
||||
});
|
||||
|
||||
it("reports error when CSS at-rules leak into the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
@media (min-width: 800px) {
|
||||
.particle {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("@media");
|
||||
});
|
||||
|
||||
it("reports leaked CSS when a style block is unclosed in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
.particle {
|
||||
color: white;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
it("does not report leaked head text for commented CSS", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<!-- .particle { color: red; } -->
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report leaked head text for valid noscript content", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<noscript>
|
||||
.no-js { display: block; }
|
||||
</noscript>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report orphan CSS for valid head metadata and style blocks", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<title>Particle Field</title>
|
||||
<meta name="description" content="Particle field">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<base href="https://example.com/">
|
||||
<style>
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports leaked head text inside template-wrapped sub-compositions", async () => {
|
||||
const html = templateCompositionWithHead(`
|
||||
</style>
|
||||
.particle { color: white; }
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
describe("timeline_id_mismatch", () => {
|
||||
it("accepts dot timeline registration", async () => {
|
||||
const html = `
|
||||
|
||||
@@ -130,17 +130,6 @@ function describeStudioElement(tag: { raw: string; name: string }): string {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
const HEAD_BLOCKS_TO_IGNORE_PATTERN =
|
||||
/<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
|
||||
const HTML_TAG_PATTERN = /<[^>]+>/g;
|
||||
const HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
|
||||
const AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
|
||||
const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
|
||||
const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
|
||||
const ORPHAN_CSS_AT_RULE_PATTERN =
|
||||
/(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
|
||||
const ORPHAN_CSS_RULE_PATTERN =
|
||||
/(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
|
||||
const VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
|
||||
const VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN =
|
||||
/<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
|
||||
@@ -150,64 +139,6 @@ interface SourceRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
function findCodeFenceLeak(headWithoutValidBlocks: string): string | null {
|
||||
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function findOrphanCssLeak(headContent: string): string | null {
|
||||
const residualText = headContent
|
||||
.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ")
|
||||
.replace(HTML_TAG_PATTERN, " ");
|
||||
return (
|
||||
ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ??
|
||||
ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function findStrayCloseLeak(headWithoutValidBlocks: string): string | null {
|
||||
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function findLeakedTextInHeadContent(headContent: string): string | null {
|
||||
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
|
||||
return (
|
||||
findCodeFenceLeak(withoutValidBlocks) ??
|
||||
findOrphanCssLeak(headContent) ??
|
||||
findStrayCloseLeak(withoutValidBlocks)
|
||||
);
|
||||
}
|
||||
|
||||
function findLeakedTextInHead(rawSource: string): string | null {
|
||||
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
|
||||
for (const match of headMatches) {
|
||||
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
||||
if (leakedText) return leakedText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLeakedTextBetweenHeadAndBody(rawSource: string): string | null {
|
||||
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
|
||||
for (const match of boundaryMatches) {
|
||||
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
||||
if (leakedText) return leakedText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLeakedTextBeforeCompositionRoot(
|
||||
source: string,
|
||||
rootTag: LintContext["rootTag"],
|
||||
): string | null {
|
||||
if (!rootTag || rootTag.name === "body") return null;
|
||||
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
||||
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
||||
const prefixEnd = rootTag.index;
|
||||
if (prefixEnd <= prefixStart) return null;
|
||||
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
||||
}
|
||||
|
||||
function findProtectedVisibleMarkupRanges(source: string): SourceRange[] {
|
||||
const ranges: SourceRange[] = [];
|
||||
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
|
||||
@@ -298,26 +229,6 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
return findings;
|
||||
},
|
||||
|
||||
// head_leaked_text
|
||||
({ source, rootTag }) => {
|
||||
const snippet =
|
||||
findLeakedTextInHead(source) ??
|
||||
findLeakedTextBetweenHeadAndBody(source) ??
|
||||
findLeakedTextBeforeCompositionRoot(source, rootTag);
|
||||
if (!snippet) return [];
|
||||
return [
|
||||
{
|
||||
code: "head_leaked_text",
|
||||
severity: "error",
|
||||
message:
|
||||
"Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
|
||||
fixHint:
|
||||
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
|
||||
snippet: truncateSnippet(snippet),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// visible_markup_comment
|
||||
({ source }) => {
|
||||
const snippet = findVisibleMarkupCommentLeak(source);
|
||||
|
||||
Reference in New Issue
Block a user