mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
feat: add Python support with Pyodide integration
GitOrigin-RevId: 382ce102c43050aace691dd89d825a94abf347a8
This commit is contained in:
committed by
Copybot
parent
bb5d90a332
commit
138f7f8023
+148
@@ -0,0 +1,148 @@
|
||||
import type {
|
||||
ProjectFileData,
|
||||
PyodideWorkerRequest,
|
||||
PyodideWorkerResponse,
|
||||
} from './pyodide-worker-messages'
|
||||
|
||||
export type OutputCallback = (
|
||||
stream: 'stdout' | 'stderr',
|
||||
line: string,
|
||||
requestId?: string
|
||||
) => void
|
||||
|
||||
export type LifecycleCallback = (
|
||||
event:
|
||||
| { type: 'loaded' }
|
||||
| { type: 'loading-failed'; error: string }
|
||||
| { type: 'run-finished'; requestId: string }
|
||||
) => void
|
||||
|
||||
export class PyodideWorkerClient {
|
||||
private worker: Worker
|
||||
private listening = false
|
||||
private loaded = false
|
||||
private destroyed = false
|
||||
private loadingError: string | null = null
|
||||
private pendingMessages: PyodideWorkerRequest[] = []
|
||||
private outputCallback: OutputCallback | null = null
|
||||
private lifecycleCallback: LifecycleCallback | null = null
|
||||
|
||||
constructor(options: { baseAssetPath: string }) {
|
||||
const { baseAssetPath } = options
|
||||
|
||||
this.worker = new Worker(
|
||||
/* webpackChunkName: "pyodide-worker" */
|
||||
new URL('./pyodide.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
)
|
||||
|
||||
this.worker.addEventListener('message', this.receive.bind(this))
|
||||
|
||||
this.queueMessage({
|
||||
type: 'init',
|
||||
baseAssetPath,
|
||||
})
|
||||
}
|
||||
|
||||
setOutputCallback(callback: OutputCallback) {
|
||||
this.outputCallback = callback
|
||||
}
|
||||
|
||||
setLifecycleCallback(handler: LifecycleCallback) {
|
||||
this.lifecycleCallback = handler
|
||||
|
||||
if (this.loaded) {
|
||||
handler({ type: 'loaded' })
|
||||
return
|
||||
}
|
||||
if (this.loadingError) {
|
||||
handler({ type: 'loading-failed', error: this.loadingError })
|
||||
}
|
||||
}
|
||||
|
||||
runCode(
|
||||
code: string,
|
||||
options: { requestId: string; files: ProjectFileData[] }
|
||||
): void {
|
||||
if (this.destroyed) {
|
||||
throw new Error('Pyodide worker client has been destroyed')
|
||||
}
|
||||
|
||||
if (this.loadingError) {
|
||||
throw new Error(this.loadingError)
|
||||
}
|
||||
|
||||
this.queueMessage({
|
||||
type: 'run-code',
|
||||
code,
|
||||
id: options.requestId,
|
||||
files: options.files,
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.destroyed = true
|
||||
this.pendingMessages.length = 0
|
||||
|
||||
if (!this.loaded && !this.loadingError) {
|
||||
this.loadingError = 'Pyodide worker was destroyed before loading finished'
|
||||
}
|
||||
|
||||
this.worker.terminate()
|
||||
}
|
||||
|
||||
private queueMessage(message: PyodideWorkerRequest) {
|
||||
if (this.listening) {
|
||||
this.worker.postMessage(message)
|
||||
} else {
|
||||
this.pendingMessages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
private receive(event: MessageEvent<PyodideWorkerResponse>) {
|
||||
const response = event.data
|
||||
|
||||
switch (response.type) {
|
||||
case 'listening':
|
||||
this.listening = true
|
||||
for (const message of this.pendingMessages) {
|
||||
this.worker.postMessage(message)
|
||||
}
|
||||
this.pendingMessages.length = 0
|
||||
return
|
||||
|
||||
case 'loaded':
|
||||
this.loaded = true
|
||||
this.lifecycleCallback?.({ type: 'loaded' })
|
||||
return
|
||||
|
||||
case 'loading-failed':
|
||||
this.loadingError = response.error
|
||||
this.pendingMessages.length = 0
|
||||
this.lifecycleCallback?.({
|
||||
type: 'loading-failed',
|
||||
error: response.error,
|
||||
})
|
||||
return
|
||||
|
||||
case 'output-line':
|
||||
this.outputCallback?.(
|
||||
response.stream,
|
||||
response.line,
|
||||
response.requestId
|
||||
)
|
||||
break
|
||||
|
||||
case 'run-code-result':
|
||||
this.lifecycleCallback?.({
|
||||
type: 'run-finished',
|
||||
requestId: response.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
export type ProjectFileData = {
|
||||
relativePath: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// Main thread -> Worker messages
|
||||
|
||||
export type InitRequest = {
|
||||
type: 'init'
|
||||
baseAssetPath: string
|
||||
}
|
||||
|
||||
export type RunCodeRequest = {
|
||||
type: 'run-code'
|
||||
id: string
|
||||
code: string
|
||||
files: ProjectFileData[]
|
||||
}
|
||||
|
||||
export type PyodideWorkerRequest = InitRequest | RunCodeRequest
|
||||
|
||||
// Worker -> Main thread lifecycle and streaming events
|
||||
|
||||
export type ListeningEvent = { type: 'listening' }
|
||||
export type LoadedEvent = { type: 'loaded' }
|
||||
export type LoadingFailedEvent = { type: 'loading-failed'; error: string }
|
||||
|
||||
export type OutputLineEvent = {
|
||||
type: 'output-line'
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export type PyodideWorkerEvent =
|
||||
| ListeningEvent
|
||||
| LoadedEvent
|
||||
| LoadingFailedEvent
|
||||
| OutputLineEvent
|
||||
|
||||
// Worker -> Main thread ID responses
|
||||
|
||||
export type RunCodeResult = {
|
||||
type: 'run-code-result'
|
||||
id: string
|
||||
}
|
||||
|
||||
export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import path from 'path-browserify'
|
||||
import type {
|
||||
ProjectFileData,
|
||||
PyodideWorkerRequest,
|
||||
RunCodeRequest,
|
||||
} from './pyodide-worker-messages'
|
||||
|
||||
type PyodideRuntimeModule = {
|
||||
loadPyodide: (options: {
|
||||
indexURL: string
|
||||
packageBaseUrl?: string
|
||||
env?: Record<string, string>
|
||||
packages?: string[]
|
||||
}) => Promise<PyodideInstance>
|
||||
}
|
||||
|
||||
type PyodideInstance = {
|
||||
FS: unknown
|
||||
runPythonAsync: (code: string) => Promise<unknown>
|
||||
setStdout: (options: { batched: (line: string) => void }) => void
|
||||
setStderr: (options: { batched: (line: string) => void }) => void
|
||||
}
|
||||
|
||||
type PyodideFs = {
|
||||
analyzePath: (filePath: string) => { exists: boolean }
|
||||
mkdir: (filePath: string) => void
|
||||
writeFile: (
|
||||
filePath: string,
|
||||
content: string | ArrayBuffer | Uint8Array
|
||||
) => void
|
||||
chdir: (filePath: string) => void
|
||||
}
|
||||
|
||||
const PROJECT_FS_ROOT = '/project'
|
||||
const PYODIDE_INDEX_PATH = 'js/libs/pyodide/'
|
||||
|
||||
function getPyodideIndexUrl(baseAssetPath: string): string {
|
||||
return new URL(PYODIDE_INDEX_PATH, baseAssetPath).toString()
|
||||
}
|
||||
|
||||
function toRuntimeProjectPath(relativePath: string): string {
|
||||
return path.posix.join(PROJECT_FS_ROOT, path.posix.normalize(relativePath))
|
||||
}
|
||||
|
||||
function ensureProjectRootExists(fs: PyodideFs) {
|
||||
try {
|
||||
const projectRootAnalysis = fs.analyzePath(PROJECT_FS_ROOT)
|
||||
if (!projectRootAnalysis.exists) {
|
||||
fs.mkdir(PROJECT_FS_ROOT)
|
||||
}
|
||||
} catch {
|
||||
fs.mkdir(PROJECT_FS_ROOT)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectoryExists(fs: PyodideFs, filePath: string) {
|
||||
const directory = path.dirname(filePath)
|
||||
if (directory === '.' || directory === '/') {
|
||||
return
|
||||
}
|
||||
|
||||
let currentPath = directory.startsWith('/') ? '/' : ''
|
||||
for (const part of directory.split('/').filter(Boolean)) {
|
||||
currentPath = path.posix.join(currentPath, part)
|
||||
try {
|
||||
const analysis = fs.analyzePath(currentPath)
|
||||
if (!analysis.exists) {
|
||||
fs.mkdir(currentPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore failures when a directory already exists.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncProjectFiles(fs: PyodideFs, files: ProjectFileData[]) {
|
||||
ensureProjectRootExists(fs)
|
||||
|
||||
for (const file of files) {
|
||||
const runtimePath = toRuntimeProjectPath(file.relativePath)
|
||||
ensureDirectoryExists(fs, runtimePath)
|
||||
fs.writeFile(runtimePath, file.content)
|
||||
}
|
||||
|
||||
fs.chdir(PROJECT_FS_ROOT)
|
||||
}
|
||||
|
||||
let pyodideInstance: PyodideInstance | null = null
|
||||
let activeRunRequestId: string | null = null
|
||||
|
||||
async function loadPyodideModule(
|
||||
pyodideIndexUrl: string
|
||||
): Promise<PyodideRuntimeModule> {
|
||||
const runtimeModuleUrl = `${pyodideIndexUrl}pyodide.mjs`
|
||||
|
||||
try {
|
||||
return (await import(
|
||||
/* webpackIgnore: true */ runtimeModuleUrl
|
||||
)) as PyodideRuntimeModule
|
||||
} catch (loadError) {
|
||||
const loadErrorMessage =
|
||||
loadError instanceof Error ? loadError.message : String(loadError)
|
||||
throw new Error(
|
||||
`Unable to load Pyodide module from ${runtimeModuleUrl}. Original error: ${loadErrorMessage}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInit(msg: { baseAssetPath: string }) {
|
||||
const pyodideIndexUrl = getPyodideIndexUrl(msg.baseAssetPath)
|
||||
|
||||
try {
|
||||
const pyodideModule = await loadPyodideModule(pyodideIndexUrl)
|
||||
const instance = await pyodideModule.loadPyodide({
|
||||
indexURL: pyodideIndexUrl,
|
||||
packageBaseUrl: pyodideIndexUrl,
|
||||
env: { MPLBACKEND: 'Agg' },
|
||||
})
|
||||
|
||||
instance.setStdout({
|
||||
batched: (line: string) => {
|
||||
self.postMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stdout',
|
||||
line,
|
||||
requestId: activeRunRequestId ?? undefined,
|
||||
})
|
||||
},
|
||||
})
|
||||
instance.setStderr({
|
||||
batched: (line: string) => {
|
||||
self.postMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line,
|
||||
requestId: activeRunRequestId ?? undefined,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
pyodideInstance = instance
|
||||
self.postMessage({ type: 'loaded' })
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error('Pyodide initialization failed', error)
|
||||
self.postMessage({
|
||||
type: 'loading-failed',
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunCode(msg: RunCodeRequest) {
|
||||
if (!pyodideInstance) {
|
||||
const error = 'Pyodide is not initialized'
|
||||
self.postMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line: error,
|
||||
requestId: msg.id,
|
||||
})
|
||||
self.postMessage({
|
||||
type: 'run-code-result',
|
||||
id: msg.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
activeRunRequestId = msg.id
|
||||
try {
|
||||
if (msg.files.length > 0) {
|
||||
const fs = pyodideInstance.FS as PyodideFs
|
||||
syncProjectFiles(fs, msg.files)
|
||||
}
|
||||
|
||||
const result = await pyodideInstance.runPythonAsync(msg.code)
|
||||
if (result !== undefined) {
|
||||
self.postMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stdout',
|
||||
line: String(result),
|
||||
requestId: activeRunRequestId ?? undefined,
|
||||
})
|
||||
}
|
||||
} catch (runError) {
|
||||
const errorMessage =
|
||||
runError instanceof Error ? runError.message : String(runError)
|
||||
self.postMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line: errorMessage,
|
||||
requestId: activeRunRequestId ?? undefined,
|
||||
})
|
||||
} finally {
|
||||
activeRunRequestId = null
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: 'run-code-result',
|
||||
id: msg.id,
|
||||
})
|
||||
}
|
||||
|
||||
self.addEventListener('message', async event => {
|
||||
const msg = event.data as PyodideWorkerRequest
|
||||
switch (msg.type) {
|
||||
case 'init':
|
||||
await handleInit(msg)
|
||||
break
|
||||
case 'run-code':
|
||||
await handleRunCode(msg)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
self.postMessage({ type: 'listening' })
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import path from 'path-browserify'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { PyodideWorkerClient } from './pyodide-worker-client'
|
||||
|
||||
export default function PythonOutputPane() {
|
||||
const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
|
||||
const { pathInFolder } = useFileTreePathContext()
|
||||
const clientRef = useRef<PyodideWorkerClient | null>(null)
|
||||
const currentRequestIdRef = useRef<string | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [output, setOutput] = useState<string[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const appendOutput = useCallback((line: string) => {
|
||||
setOutput(previousOutput => [...previousOutput, line])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const baseAssetPath = new URL(
|
||||
getMeta('ol-baseAssetPath'),
|
||||
window.location.href
|
||||
).toString()
|
||||
const client = new PyodideWorkerClient({ baseAssetPath })
|
||||
clientRef.current = client
|
||||
let cancelled = false
|
||||
|
||||
client.setLifecycleCallback(event => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'loaded':
|
||||
setIsReady(true)
|
||||
setIsLoadingPyodide(false)
|
||||
return
|
||||
|
||||
case 'loading-failed':
|
||||
debugConsole.error('Failed to load Python runtime', event.error)
|
||||
setError(formatError(event.error))
|
||||
setIsLoadingPyodide(false)
|
||||
setIsRunning(false)
|
||||
return
|
||||
|
||||
case 'run-finished':
|
||||
if (event.requestId !== currentRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
currentRequestIdRef.current = null
|
||||
setIsRunning(false)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
client.setOutputCallback((_stream, line, requestId) => {
|
||||
if (!requestId || requestId !== currentRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
appendOutput(line)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
currentRequestIdRef.current = null
|
||||
client.destroy()
|
||||
clientRef.current = null
|
||||
}
|
||||
}, [appendOutput])
|
||||
|
||||
useEffect(() => {
|
||||
currentRequestIdRef.current = null
|
||||
setIsRunning(false)
|
||||
setOutput([])
|
||||
setError(null)
|
||||
}, [currentDocumentId])
|
||||
|
||||
const buildCurrentDocumentSyncFile = useCallback(() => {
|
||||
if (!currentDocument || !currentDocumentId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = currentDocument.getSnapshot()
|
||||
if (typeof content !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentPath = pathInFolder(currentDocumentId)
|
||||
if (!currentPath) {
|
||||
throw new Error(
|
||||
'Unable to resolve current document path for Python sync.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
relativePath: path.posix.normalize(currentPath),
|
||||
content,
|
||||
}
|
||||
}, [currentDocument, currentDocumentId, pathInFolder])
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
const client = clientRef.current
|
||||
if (!client || !isReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const syncFile = buildCurrentDocumentSyncFile()
|
||||
if (!syncFile) {
|
||||
return
|
||||
}
|
||||
|
||||
setOutput([])
|
||||
setError(null)
|
||||
|
||||
const requestId = syncFile.relativePath
|
||||
currentRequestIdRef.current = requestId
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
client.runCode(syncFile.content, { requestId, files: [syncFile] })
|
||||
} catch (runError) {
|
||||
if (currentRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
currentRequestIdRef.current = null
|
||||
setIsRunning(false)
|
||||
setError(formatError(runError))
|
||||
}
|
||||
}, [buildCurrentDocumentSyncFile, isReady])
|
||||
|
||||
return (
|
||||
<div className="ide-redesign-python-output-pane">
|
||||
<OLButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
|
||||
<div className="toolbar-pdf-left">
|
||||
<div className="compile-button-group">
|
||||
<OLButton
|
||||
onClick={handleRun}
|
||||
variant="primary"
|
||||
className="compile-button align-items-center py-0 px-3"
|
||||
disabled={!isReady || isLoadingPyodide || isRunning}
|
||||
aria-label="Run Python Code"
|
||||
style={{ borderRadius: '12px' }}
|
||||
>
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
<MaterialIcon type="play_arrow" className="ml-2" />
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
</OLButtonToolbar>
|
||||
|
||||
<div className="ide-redesign-python-output-pane-body">
|
||||
{isLoadingPyodide && (
|
||||
<div className="ide-redesign-python-output-pane-placeholder">
|
||||
Loading Python runtime...
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingPyodide && !error && output.length === 0 && (
|
||||
<div className="ide-redesign-python-output-pane-placeholder">
|
||||
Run the current script to see output.
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="ide-redesign-python-output-pane-error">{error}</div>
|
||||
)}
|
||||
{output.map((line, index) => (
|
||||
<div className="ide-redesign-python-output-pane-line" key={index}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import { Suspense } from 'react'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { PythonEditorSplit } from '@/features/ide-react/components/layout/python-editor-split'
|
||||
|
||||
export const Editor = () => {
|
||||
const { opening, errorState, showSymbolPalette } =
|
||||
useEditorPropertiesContext()
|
||||
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
|
||||
const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
|
||||
const isPythonDocument =
|
||||
openEntity?.type === 'doc' &&
|
||||
openEntity.entity.name.toLowerCase().endsWith('.py')
|
||||
|
||||
if (!currentDocumentId) {
|
||||
return null
|
||||
@@ -39,7 +43,7 @@ export const Editor = () => {
|
||||
order={1}
|
||||
className="ide-redesign-editor-panel"
|
||||
>
|
||||
<SourceEditor />
|
||||
{isPythonDocument ? <PythonEditorSplit /> : <SourceEditor />}
|
||||
{isLoading && <EditorLoadingPane />}
|
||||
</Panel>
|
||||
{showSymbolPalette && (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||
import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
|
||||
import SourceEditor from '@/features/source-editor/components/source-editor'
|
||||
|
||||
export const PythonEditorSplit = () => {
|
||||
return (
|
||||
<PanelGroup
|
||||
autoSaveId="ide-redesign-editor-python-output"
|
||||
direction="vertical"
|
||||
className="ide-redesign-python-editor-split"
|
||||
>
|
||||
<Panel id="ide-redesign-panel-source-editor-content" order={1}>
|
||||
<SourceEditor />
|
||||
</Panel>
|
||||
<VerticalResizeHandle id="ide-redesign-editor-python-output" />
|
||||
<Panel
|
||||
id="ide-redesign-panel-python-output"
|
||||
order={2}
|
||||
defaultSize={35}
|
||||
minSize={10}
|
||||
>
|
||||
<PythonOutputPane />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user