generated from nhcarrigan/template
feat: linkify MCP binary file paths in markdown output
Detects local binary file paths (PDF, audio, video, Office docs, archives) in Claude's responses and renders them as styled, clickable links that open via the system's default viewer. Skips paths inside code blocks so existing code examples are unaffected. Closes #211
This commit is contained in:
@@ -2,8 +2,9 @@
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl, openPath } from "@tauri-apps/plugin-opener";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
import { linkifyFilePaths } from "$lib/utils/filePaths";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -113,7 +114,8 @@
|
|||||||
let parsedHtml = $derived.by(() => {
|
let parsedHtml = $derived.by(() => {
|
||||||
try {
|
try {
|
||||||
const html = marked.parse(content) as string;
|
const html = marked.parse(content) as string;
|
||||||
return processSpoilers(html);
|
const withSpoilers = processSpoilers(html);
|
||||||
|
return linkifyFilePaths(withSpoilers);
|
||||||
} catch {
|
} catch {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
@@ -140,9 +142,18 @@
|
|||||||
function handleLinkClick(event: MouseEvent) {
|
function handleLinkClick(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const anchor = target.closest("a");
|
const anchor = target.closest("a");
|
||||||
if (anchor?.href) {
|
if (!anchor) return;
|
||||||
event.preventDefault();
|
|
||||||
openUrl(anchor.href);
|
event.preventDefault();
|
||||||
|
|
||||||
|
const filePath = anchor.dataset.filepath;
|
||||||
|
if (filePath) {
|
||||||
|
void openPath(filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor.href) {
|
||||||
|
void openUrl(anchor.href);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,4 +464,27 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-content :global(.file-link) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
color: var(--accent-primary, #f472b6);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :global(.file-link:hover) {
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent);
|
||||||
|
color: var(--accent-secondary, #e879f9);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
BINARY_FILE_EXTENSIONS,
|
||||||
|
getFileExtension,
|
||||||
|
getFileTypeIcon,
|
||||||
|
isBinaryFilePath,
|
||||||
|
linkifyFilePaths,
|
||||||
|
} from "./filePaths";
|
||||||
|
|
||||||
|
describe("getFileExtension", () => {
|
||||||
|
it("returns the lowercase extension of a simple path", () => {
|
||||||
|
expect(getFileExtension("/tmp/report.pdf")).toBe("pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the lowercase extension for uppercase file names", () => {
|
||||||
|
expect(getFileExtension("/tmp/AUDIO.MP3")).toBe("mp3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the extension for a path with multiple dots", () => {
|
||||||
|
expect(getFileExtension("/tmp/my.file.docx")).toBe("docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string when there is no extension", () => {
|
||||||
|
expect(getFileExtension("/tmp/noextension")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string for an empty string input", () => {
|
||||||
|
expect(getFileExtension("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the extension for a home-relative path", () => {
|
||||||
|
expect(getFileExtension("~/downloads/track.wav")).toBe("wav");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFileTypeIcon", () => {
|
||||||
|
it("returns the PDF icon for .pdf files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/doc.pdf")).toBe("📄");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the Word icon for .docx files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/report.docx")).toBe("📝");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the Word icon for .doc files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/old.doc")).toBe("📝");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the spreadsheet icon for .xlsx files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/data.xlsx")).toBe("📊");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the spreadsheet icon for .xls files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/data.xls")).toBe("📊");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the presentation icon for .pptx files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/slides.pptx")).toBe("📽️");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the presentation icon for .ppt files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/slides.ppt")).toBe("📽️");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .mp3 files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/song.mp3")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .wav files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/sound.wav")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .ogg files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/audio.ogg")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .flac files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/lossless.flac")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .aac files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/compressed.aac")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .m4a files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/itunes.m4a")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .mp4 files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/video.mp4")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .avi files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/old.avi")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .mov files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/clip.mov")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .mkv files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/film.mkv")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .webm files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/stream.webm")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the archive icon for .zip files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/bundle.zip")).toBe("📦");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the archive icon for .tar files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/archive.tar")).toBe("📦");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the archive icon for .gz files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/compressed.gz")).toBe("📦");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the disk icon for .bin files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/firmware.bin")).toBe("💿");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the disk icon for .iso files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/image.iso")).toBe("💿");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the generic folder icon for an unknown extension", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/file.unknown")).toBe("📁");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the generic folder icon for a file with no extension", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/noext")).toBe("📁");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBinaryFilePath", () => {
|
||||||
|
it("returns true for a PDF path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/report.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for an audio path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/song.mp3")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a video path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/clip.mp4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a document path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/doc.docx")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a TypeScript file", () => {
|
||||||
|
expect(isBinaryFilePath("/src/index.ts")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a text file", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/output.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a path with no extension", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/file")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BINARY_FILE_EXTENSIONS", () => {
|
||||||
|
it("includes pdf", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes common audio extensions", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("mp3");
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("wav");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes common video extensions", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes common document extensions", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("docx");
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("xlsx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linkifyFilePaths", () => {
|
||||||
|
it("converts a PDF path in plain text to a file link", () => {
|
||||||
|
const html = "<p>Saved to /tmp/report.pdf successfully.</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="/tmp/report.pdf"');
|
||||||
|
expect(result).toContain("📄");
|
||||||
|
expect(result).toContain('class="file-link"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts an audio path to a file link", () => {
|
||||||
|
const html = "<p>Audio saved to /tmp/output.mp3</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="/tmp/output.mp3"');
|
||||||
|
expect(result).toContain("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify paths inside code blocks", () => {
|
||||||
|
const html = "<p>Example:</p><pre><code>/tmp/file.pdf</code></pre>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
|
||||||
|
expect(result).toContain("/tmp/file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify paths inside inline code", () => {
|
||||||
|
const html = "<p>Use <code>/tmp/file.pdf</code> to open it.</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
|
||||||
|
expect(result).toContain("/tmp/file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not modify HTML that has no binary file paths", () => {
|
||||||
|
const html = "<p>Hello, this is regular text with /tmp/script.sh</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify text file paths", () => {
|
||||||
|
const html = "<p>Saved to /tmp/output.txt</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain("data-filepath");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a home-relative path", () => {
|
||||||
|
const html = "<p>Saved to ~/downloads/audio.flac</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="~/downloads/audio.flac"');
|
||||||
|
expect(result).toContain("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple file paths in the same HTML", () => {
|
||||||
|
const html = "<p>Files: /tmp/a.pdf and /tmp/b.mp3</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="/tmp/a.pdf"');
|
||||||
|
expect(result).toContain('data-filepath="/tmp/b.mp3"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify paths that contain double quotes (invalid path character)", () => {
|
||||||
|
// Double quotes are excluded from path chars so the path is not matched
|
||||||
|
const html = `<p>Saved to /tmp/my"file.pdf</p>`;
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain("data-filepath");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing HTML tags and attributes", () => {
|
||||||
|
const html = '<p class="foo">Saved to /tmp/report.pdf</p>';
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('class="foo"');
|
||||||
|
expect(result).toContain('data-filepath="/tmp/report.pdf"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-linkify a path already inside an anchor tag", () => {
|
||||||
|
const html = '<a href="/tmp/file.pdf">/tmp/file.pdf</a>';
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
// The href is inside a tag (placeholder), the text content IS linkified
|
||||||
|
// but the href itself should not be modified
|
||||||
|
const hrefMatches = result.match(/href="[^"]*\/tmp\/file\.pdf[^"]*"/g) ?? [];
|
||||||
|
expect(hrefMatches.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the input unchanged when html is empty", () => {
|
||||||
|
expect(linkifyFilePaths("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for detecting and rendering binary file paths
|
||||||
|
* saved to disk by MCP tools via the Claude Code CLI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BINARY_FILE_EXTENSIONS = [
|
||||||
|
// Documents
|
||||||
|
"pdf",
|
||||||
|
"docx",
|
||||||
|
"doc",
|
||||||
|
"xlsx",
|
||||||
|
"xls",
|
||||||
|
"pptx",
|
||||||
|
"ppt",
|
||||||
|
// Audio
|
||||||
|
"mp3",
|
||||||
|
"wav",
|
||||||
|
"ogg",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"m4a",
|
||||||
|
// Video
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
|
// Archives
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
// Other binaries
|
||||||
|
"bin",
|
||||||
|
"iso",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BinaryFileExtension = (typeof BINARY_FILE_EXTENSIONS)[number];
|
||||||
|
|
||||||
|
export function getFileExtension(filePath: string): string {
|
||||||
|
const lastDot = filePath.lastIndexOf(".");
|
||||||
|
if (lastDot === -1) return "";
|
||||||
|
return filePath.slice(lastDot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileTypeIcon(filePath: string): string {
|
||||||
|
const ext = getFileExtension(filePath);
|
||||||
|
switch (ext) {
|
||||||
|
case "pdf":
|
||||||
|
return "📄";
|
||||||
|
case "docx":
|
||||||
|
case "doc":
|
||||||
|
return "📝";
|
||||||
|
case "xlsx":
|
||||||
|
case "xls":
|
||||||
|
return "📊";
|
||||||
|
case "pptx":
|
||||||
|
case "ppt":
|
||||||
|
return "📽️";
|
||||||
|
case "mp3":
|
||||||
|
case "wav":
|
||||||
|
case "ogg":
|
||||||
|
case "flac":
|
||||||
|
case "aac":
|
||||||
|
case "m4a":
|
||||||
|
return "🎵";
|
||||||
|
case "mp4":
|
||||||
|
case "avi":
|
||||||
|
case "mov":
|
||||||
|
case "mkv":
|
||||||
|
case "webm":
|
||||||
|
return "🎬";
|
||||||
|
case "zip":
|
||||||
|
case "tar":
|
||||||
|
case "gz":
|
||||||
|
return "📦";
|
||||||
|
case "bin":
|
||||||
|
case "iso":
|
||||||
|
return "💿";
|
||||||
|
default:
|
||||||
|
return "📁";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBinaryFilePath(filePath: string): boolean {
|
||||||
|
const ext = getFileExtension(filePath);
|
||||||
|
return (BINARY_FILE_EXTENSIONS as readonly string[]).includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-processes HTML content to convert binary file paths into clickable
|
||||||
|
* anchor elements with file-type icons. Skips content inside code blocks
|
||||||
|
* and existing HTML tags so it doesn't double-linkify or corrupt attributes.
|
||||||
|
*/
|
||||||
|
export function linkifyFilePaths(html: string): string {
|
||||||
|
const codeBlockPlaceholders: string[] = [];
|
||||||
|
|
||||||
|
// Temporarily replace code blocks and inline code with placeholders
|
||||||
|
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||||
|
codeBlockPlaceholders.push(match);
|
||||||
|
return `__FILEPATH_CODE_${codeBlockPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Temporarily replace all HTML tags with placeholders
|
||||||
|
const tagPlaceholders: string[] = [];
|
||||||
|
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||||
|
tagPlaceholders.push(match);
|
||||||
|
return `__FILEPATH_TAG_${tagPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now replace binary file paths in the remaining plain text
|
||||||
|
const extensions = BINARY_FILE_EXTENSIONS.join("|");
|
||||||
|
// No lookahead needed — the greedy character class naturally backtracks to the
|
||||||
|
// shortest match ending with a recognised extension, terminating before any
|
||||||
|
// character excluded by the class (spaces, HTML-unsafe chars, tag placeholders).
|
||||||
|
const filePathRegex = new RegExp(`((?:~/|/)[^\\s<>"'\`]+\\.(?:${extensions}))`, "gi");
|
||||||
|
processed = processed.replace(filePathRegex, (_, filePath: string) => {
|
||||||
|
const icon = getFileTypeIcon(filePath);
|
||||||
|
const escaped = filePath.replace(/"/g, """);
|
||||||
|
return `<a class="file-link" href="#" data-filepath="${escaped}">${icon} ${filePath}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore HTML tags
|
||||||
|
processed = processed.replace(/__FILEPATH_TAG_(\d+)__/g, (_, index) => {
|
||||||
|
return tagPlaceholders[parseInt(index, 10)];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore code blocks
|
||||||
|
processed = processed.replace(/__FILEPATH_CODE_(\d+)__/g, (_, index) => {
|
||||||
|
return codeBlockPlaceholders[parseInt(index, 10)];
|
||||||
|
});
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user