Use pnpm and more clean-up of sandbox mode
This commit is contained in:
@@ -3,18 +3,6 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { RuntimeMode } from "@/lib/schemas";
|
||||
|
||||
function formatRuntimeMode(runtimeMode: RuntimeMode | undefined) {
|
||||
switch (runtimeMode) {
|
||||
case "web-sandbox":
|
||||
return "Sandboxed";
|
||||
case "local-node":
|
||||
return "Full Access";
|
||||
default:
|
||||
return runtimeMode;
|
||||
}
|
||||
}
|
||||
|
||||
export const TitleBar = () => {
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
@@ -32,9 +20,7 @@ export const TitleBar = () => {
|
||||
<div className="@container z-11 w-full h-8 bg-(--sidebar) absolute top-0 left-0 app-region-drag flex items-center">
|
||||
<div className="pl-24"></div>
|
||||
<div className="hidden @md:block text-sm font-medium">{displayText}</div>
|
||||
<div className="text-sm font-medium pl-4">
|
||||
{formatRuntimeMode(settings?.runtimeMode)} mode
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-center text-sm font-medium">Dyad</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export function SetupBanner() {
|
||||
const navigate = useNavigate();
|
||||
const { isAnyProviderSetup } = useSettings();
|
||||
const [nodeVersion, setNodeVersion] = useState<string | null>(null);
|
||||
const [pnpmVersion, setPnpmVersion] = useState<string | null>(null);
|
||||
const [nodeCheckError, setNodeCheckError] = useState<boolean>(false);
|
||||
const [nodeInstallError, setNodeInstallError] = useState<string | null>(null);
|
||||
const [nodeInstallLoading, setNodeInstallLoading] = useState<boolean>(false);
|
||||
@@ -34,9 +35,11 @@ export function SetupBanner() {
|
||||
setNodeCheckError(false);
|
||||
const status = await IpcClient.getInstance().getNodejsStatus();
|
||||
setNodeVersion(status.nodeVersion);
|
||||
setPnpmVersion(status.pnpmVersion);
|
||||
} catch (error) {
|
||||
console.error("Failed to check Node.js status:", error);
|
||||
setNodeVersion(null);
|
||||
setPnpmVersion(null);
|
||||
setNodeCheckError(true);
|
||||
}
|
||||
}, [setNodeVersion, setNodeCheckError]);
|
||||
@@ -60,7 +63,8 @@ export function SetupBanner() {
|
||||
showError(result.errorMessage);
|
||||
setNodeInstallError(result.errorMessage || "Unknown error");
|
||||
} else {
|
||||
setNodeVersion(result.version);
|
||||
setNodeVersion(result.nodeVersion);
|
||||
setPnpmVersion(result.pnpmVersion);
|
||||
}
|
||||
} catch (error) {
|
||||
showError("Failed to install Node.js. " + (error as Error).message);
|
||||
@@ -72,7 +76,7 @@ export function SetupBanner() {
|
||||
}
|
||||
};
|
||||
|
||||
const isNodeSetupComplete = !!nodeVersion;
|
||||
const isNodeSetupComplete = !!nodeVersion && !!pnpmVersion;
|
||||
const isAiProviderSetup = isAnyProviderSetup();
|
||||
|
||||
const itemsNeedAction: string[] = [];
|
||||
@@ -143,7 +147,9 @@ export function SetupBanner() {
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm mb-3">
|
||||
Node.js is required to run apps locally.
|
||||
Node.js is required to run apps locally. We also use pnpm as
|
||||
our package manager as it's faster and more efficient than
|
||||
npm.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { selectedAppIdAtom, appUrlAtom, appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Loader2,
|
||||
X,
|
||||
Sparkles,
|
||||
@@ -25,14 +23,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import {
|
||||
loadSandpackClient,
|
||||
type SandboxSetup,
|
||||
type ClientOptions,
|
||||
SandpackClient,
|
||||
} from "@codesandbox/sandpack-client";
|
||||
import { showError } from "@/lib/toast";
|
||||
import { SandboxConfig } from "@/ipc/ipc_types";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string | undefined;
|
||||
@@ -325,24 +315,14 @@ export const PreviewIframe = ({
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={
|
||||
!canGoBack ||
|
||||
loading ||
|
||||
!selectedAppId ||
|
||||
settings?.runtimeMode === "web-sandbox"
|
||||
}
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={
|
||||
!canGoForward ||
|
||||
loading ||
|
||||
!selectedAppId ||
|
||||
settings?.runtimeMode === "web-sandbox"
|
||||
}
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
@@ -394,12 +374,6 @@ export const PreviewIframe = ({
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
title={
|
||||
settings?.runtimeMode === "web-sandbox"
|
||||
? "Open in browser (disabled in sandbox mode)"
|
||||
: undefined
|
||||
}
|
||||
disabled={settings?.runtimeMode === "web-sandbox"}
|
||||
onClick={() => {
|
||||
if (appUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(appUrl);
|
||||
@@ -421,9 +395,7 @@ export const PreviewIframe = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{settings?.runtimeMode === "web-sandbox" ? (
|
||||
<SandpackIframe reloadKey={reloadKey} iframeRef={iframeRef} />
|
||||
) : !appUrl ? (
|
||||
{!appUrl ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
@@ -443,120 +415,3 @@ export const PreviewIframe = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const parseTailwindConfig = (config: string) => {
|
||||
const themeRegex = /theme\s*:\s*(\{[\s\S]*?\})(?=\s*,\s*plugins)/;
|
||||
const match = config.match(themeRegex);
|
||||
if (!match) return "{};";
|
||||
return `{theme: ${match[1]}};`;
|
||||
};
|
||||
|
||||
const SandpackIframe = ({
|
||||
reloadKey,
|
||||
iframeRef,
|
||||
}: {
|
||||
reloadKey: number;
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
}) => {
|
||||
const [appUrlObj, setAppUrlObj] = useAtom(appUrlAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { app } = useLoadApp(selectedAppId);
|
||||
const keyRef = useRef<number | null>(null);
|
||||
const sandpackClientRef = useRef<SandpackClient | null>(null);
|
||||
|
||||
async function loadSandpack() {
|
||||
if (keyRef.current === reloadKey) return;
|
||||
keyRef.current = reloadKey;
|
||||
|
||||
if (!selectedAppId) return;
|
||||
if (sandpackClientRef.current) {
|
||||
sandpackClientRef.current.destroy();
|
||||
sandpackClientRef.current = null;
|
||||
}
|
||||
const sandboxConfig = await IpcClient.getInstance().getAppSandboxConfig(
|
||||
selectedAppId
|
||||
);
|
||||
|
||||
if (!iframeRef.current || !app) return;
|
||||
|
||||
const sandpackConfig: SandboxSetup = mapSandpackConfig(sandboxConfig);
|
||||
const url = "http://localhost:31111";
|
||||
const options: ClientOptions = {
|
||||
bundlerURL: url,
|
||||
showOpenInCodeSandbox: false,
|
||||
showLoadingScreen: true,
|
||||
showErrorScreen: true,
|
||||
externalResources: ["https://cdn.tailwindcss.com"],
|
||||
};
|
||||
|
||||
let client: SandpackClient | undefined;
|
||||
try {
|
||||
client = await loadSandpackClient(
|
||||
iframeRef.current,
|
||||
sandpackConfig,
|
||||
options
|
||||
);
|
||||
setAppUrlObj({
|
||||
appUrl: url,
|
||||
appId: selectedAppId,
|
||||
});
|
||||
sandpackClientRef.current = client;
|
||||
return client;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function updateSandpack() {
|
||||
if (sandpackClientRef.current && selectedAppId) {
|
||||
const sandboxConfig = await IpcClient.getInstance().getAppSandboxConfig(
|
||||
selectedAppId
|
||||
);
|
||||
sandpackClientRef.current.updateSandbox(
|
||||
mapSandpackConfig(sandboxConfig)
|
||||
);
|
||||
}
|
||||
}
|
||||
updateSandpack();
|
||||
}, [app]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current || !app || !selectedAppId) return;
|
||||
loadSandpack();
|
||||
}, [reloadKey]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full h-full border-none bg-gray-50"
|
||||
></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSandpackConfig = (sandboxConfig: SandboxConfig): SandboxSetup => {
|
||||
return {
|
||||
files: Object.fromEntries(
|
||||
Object.entries(sandboxConfig.files).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
code: value.replace(
|
||||
"import './globals.css'",
|
||||
`
|
||||
const injectedStyle = document.createElement("style");
|
||||
injectedStyle.textContent = \`${sandboxConfig.files["src/globals.css"]}\`;
|
||||
injectedStyle.type = "text/tailwindcss";
|
||||
document.head.appendChild(injectedStyle);
|
||||
|
||||
window.tailwind.config = ${parseTailwindConfig(
|
||||
sandboxConfig.files["tailwind.config.ts"]
|
||||
)}
|
||||
`
|
||||
),
|
||||
},
|
||||
])
|
||||
),
|
||||
dependencies: sandboxConfig.dependencies,
|
||||
entry: sandboxConfig.entry,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ import { getGitAuthor } from "../utils/git_author";
|
||||
import killPort from "kill-port";
|
||||
import util from "util";
|
||||
// Needed, otherwise electron in MacOS/Linux will not be able
|
||||
// to find "npm".
|
||||
// to find node/pnpm.
|
||||
fixPath();
|
||||
|
||||
// Keep track of the static file server worker
|
||||
@@ -54,87 +54,7 @@ async function executeApp({
|
||||
appId: number;
|
||||
event: Electron.IpcMainInvokeEvent;
|
||||
}): Promise<void> {
|
||||
// Return type is void, communication happens via event.sender.send
|
||||
const settings = readSettings();
|
||||
if (settings.runtimeMode === "web-sandbox") {
|
||||
// If server is already running, do nothing.
|
||||
if (staticServerWorker) {
|
||||
console.log(`Static server already running on port ${staticServerPort}`);
|
||||
// No need to send app:output here
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the worker if it's not running
|
||||
console.log(`Starting static file server worker for the first time.`);
|
||||
// No need to send starting status
|
||||
|
||||
const workerScriptPath = path.resolve(
|
||||
__dirname,
|
||||
"../../worker/static_file_server.js"
|
||||
);
|
||||
|
||||
// Check if worker script exists
|
||||
if (!fs.existsSync(workerScriptPath)) {
|
||||
const errorMsg = `Worker script not found at ${workerScriptPath}. Build process might be incomplete.`;
|
||||
console.error(errorMsg);
|
||||
// No need to send error status via event
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
staticServerWorker = new Worker(workerScriptPath, {
|
||||
workerData: {
|
||||
rootDir: path.join(__dirname, "..", "..", "sandpack-generated"), // Use the appPath of the first app run in this mode
|
||||
// Optionally pass other config like port preference
|
||||
// port: 3001 // Example
|
||||
},
|
||||
});
|
||||
// staticServerRootDir = appPath; // Removed
|
||||
|
||||
staticServerWorker.on("message", (message) => {
|
||||
console.log(
|
||||
`Message from static server worker: ${JSON.stringify(message)}`
|
||||
);
|
||||
if (message.status === "ready" && message.port) {
|
||||
staticServerPort = message.port;
|
||||
console.log(`Static file server ready on port ${staticServerPort}`);
|
||||
// No need to send ready status
|
||||
} else if (message.status === "error") {
|
||||
console.error(`Static file server worker error: ${message.message}`);
|
||||
// No need to send error status
|
||||
// Terminate the failed worker
|
||||
staticServerWorker?.terminate();
|
||||
staticServerWorker = null;
|
||||
staticServerPort = null;
|
||||
}
|
||||
});
|
||||
|
||||
staticServerWorker.on("error", (error) => {
|
||||
console.error(`Static file server worker encountered an error:`, error);
|
||||
// No need to send error status
|
||||
staticServerWorker = null; // Worker is likely unusable
|
||||
staticServerPort = null;
|
||||
});
|
||||
|
||||
staticServerWorker.on("exit", (code) => {
|
||||
console.log(`Static file server worker exited with code ${code}`);
|
||||
// Clear state if the worker exits unexpectedly
|
||||
if (staticServerWorker) {
|
||||
// Check avoids race condition if terminated intentionally
|
||||
staticServerWorker = null;
|
||||
staticServerPort = null;
|
||||
// No need to send exit status
|
||||
}
|
||||
});
|
||||
|
||||
return; // Return void
|
||||
}
|
||||
if (settings.runtimeMode === "local-node") {
|
||||
// Ensure worker isn't running if switching modes (optional, depends on desired behavior)
|
||||
// if (staticServerWorker) { await staticServerWorker.terminate(); staticServerWorker = null; staticServerPort = null; }
|
||||
await executeAppLocalNode({ appPath, appId, event });
|
||||
return;
|
||||
}
|
||||
throw new Error(`Invalid runtime mode: ${settings.runtimeMode}`);
|
||||
await executeAppLocalNode({ appPath, appId, event });
|
||||
}
|
||||
async function executeAppLocalNode({
|
||||
appPath,
|
||||
@@ -145,7 +65,7 @@ async function executeAppLocalNode({
|
||||
appId: number;
|
||||
event: Electron.IpcMainInvokeEvent;
|
||||
}): Promise<void> {
|
||||
const process = spawn("npm install && npm run dev -- --port 32100", [], {
|
||||
const process = spawn("pnpm install && pnpm run dev -- --port 32100", [], {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close
|
||||
@@ -219,40 +139,6 @@ async function killProcessOnPort(port: number): Promise<void> {
|
||||
}
|
||||
|
||||
export function registerAppHandlers() {
|
||||
ipcMain.handle(
|
||||
"get-app-sandbox-config",
|
||||
async (_, { appId }: { appId: number }): Promise<SandboxConfig> => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const files = getFilesRecursively(appPath, appPath);
|
||||
|
||||
const filesMap = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const content = await fs.promises.readFile(
|
||||
path.join(appPath, file),
|
||||
"utf-8"
|
||||
);
|
||||
return { [file]: content };
|
||||
})
|
||||
);
|
||||
|
||||
// Get dependencies from package.json
|
||||
const packageJsonPath = path.join(appPath, "package.json");
|
||||
const packageJson = await fs.promises.readFile(packageJsonPath, "utf-8");
|
||||
const dependencies = JSON.parse(packageJson).dependencies;
|
||||
|
||||
return {
|
||||
files: filesMap.reduce((acc, file) => ({ ...acc, ...file }), {}),
|
||||
dependencies,
|
||||
entry: "src/main.tsx",
|
||||
};
|
||||
}
|
||||
);
|
||||
ipcMain.handle("create-app", async (_, params: CreateAppParams) => {
|
||||
const appPath = params.name;
|
||||
const fullAppPath = getDyadAppPath(appPath);
|
||||
@@ -441,21 +327,16 @@ export function registerAppHandlers() {
|
||||
console.log(
|
||||
`Attempting to stop app ${appId} (local-node only). Current running apps: ${runningApps.size}`
|
||||
);
|
||||
|
||||
// Static server worker is NOT terminated here anymore
|
||||
|
||||
// Use withLock for local-node apps
|
||||
return withLock(appId, async () => {
|
||||
const appInfo = runningApps.get(appId);
|
||||
|
||||
if (!appInfo) {
|
||||
console.log(
|
||||
`App ${appId} not found in running apps map (local-node). Assuming already stopped or was web-sandbox.`
|
||||
`App ${appId} not found in running apps map (local-node). Assuming already stopped.`
|
||||
);
|
||||
// If no local-node app was running, and we terminated the static server above, return success.
|
||||
return {
|
||||
success: true,
|
||||
message: "App not running in local-node mode.", // Simplified message
|
||||
message: "App not running.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,10 +392,7 @@ export function registerAppHandlers() {
|
||||
`Stopping local-node app ${appId} (processId ${processId}) before restart` // Adjusted log
|
||||
);
|
||||
|
||||
// Use the killProcess utility to stop the process
|
||||
await killProcess(process);
|
||||
|
||||
// Remove from running apps
|
||||
runningApps.delete(appId);
|
||||
} else {
|
||||
console.log(
|
||||
|
||||
@@ -91,14 +91,14 @@ export function registerNodeHandlers() {
|
||||
"nodejs-status",
|
||||
async (): Promise<{
|
||||
nodeVersion: string | null;
|
||||
npmVersion: string | null;
|
||||
pnpmVersion: string | null;
|
||||
}> => {
|
||||
// Run checks in parallel
|
||||
const [nodeVersion, npmVersion] = await Promise.all([
|
||||
const [nodeVersion, pnpmVersion] = await Promise.all([
|
||||
checkCommandExists("node"),
|
||||
checkCommandExists("npm"),
|
||||
checkCommandExists("pnpm"),
|
||||
]);
|
||||
return { nodeVersion, npmVersion };
|
||||
return { nodeVersion, pnpmVersion };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -129,8 +129,22 @@ export function registerNodeHandlers() {
|
||||
if (!result.success) {
|
||||
return { success: false, errorMessage: result.errorMessage };
|
||||
}
|
||||
console.log("Node.js is setup with version");
|
||||
const nodeVersion = result.output.trim();
|
||||
console.log("Node.js is setup with version", nodeVersion);
|
||||
|
||||
return { success: true, version: result.output };
|
||||
result = await runShell("corepack enable pnpm");
|
||||
if (!result.success) {
|
||||
return { success: false, errorMessage: result.errorMessage };
|
||||
}
|
||||
console.log("Enabled pnpm");
|
||||
|
||||
result = await runShell("pnpm --version");
|
||||
if (!result.success) {
|
||||
return { success: false, errorMessage: result.errorMessage };
|
||||
}
|
||||
const pnpmVersion = result.output.trim();
|
||||
console.log("pnpm is setup with version", pnpmVersion);
|
||||
|
||||
return { success: true, nodeVersion, pnpmVersion };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,18 +143,6 @@ export class IpcClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async getAppSandboxConfig(appId: number): Promise<SandboxConfig> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-app-sandbox-config", {
|
||||
appId,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getApp(appId: number): Promise<App> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-app", appId);
|
||||
@@ -507,14 +495,11 @@ export class IpcClient {
|
||||
// Check Node.js and npm status
|
||||
public async getNodejsStatus(): Promise<{
|
||||
nodeVersion: string | null;
|
||||
npmVersion: string | null;
|
||||
pnpmVersion: string | null;
|
||||
}> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("nodejs-status");
|
||||
return result as {
|
||||
nodeVersion: string | null;
|
||||
npmVersion: string | null;
|
||||
};
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
|
||||
@@ -69,7 +69,8 @@ export interface SandboxConfig {
|
||||
export type InstallNodeResult =
|
||||
| {
|
||||
success: true;
|
||||
version: string;
|
||||
nodeVersion: string;
|
||||
pnpmVersion: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Main Home Page Content (Rendered only if runtimeMode is set)
|
||||
// Main Home Page Content
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center max-w-3xl m-auto p-8">
|
||||
<h1 className="text-6xl font-bold mb-12 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-gray-100 dark:to-gray-400 tracking-tight">
|
||||
|
||||
@@ -15,7 +15,6 @@ const validInvokeChannels = [
|
||||
"get-chats",
|
||||
"list-apps",
|
||||
"get-app",
|
||||
"get-app-sandbox-config",
|
||||
"edit-app-file",
|
||||
"read-app-file",
|
||||
"run-app",
|
||||
|
||||
Reference in New Issue
Block a user