Remove global settings test form
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -59,31 +59,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
marginBottom: theme.spacing[4],
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
hostSelectorRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
},
|
|
||||||
hostSelectorChip: {
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
backgroundColor: theme.colors.card,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
hostSelectorChipSelected: {
|
|
||||||
backgroundColor: theme.colors.foreground,
|
|
||||||
borderColor: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
hostSelectorChipText: {
|
|
||||||
color: theme.colors.mutedForeground,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
hostSelectorChipTextSelected: {
|
|
||||||
color: theme.colors.background,
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -96,51 +71,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
inputDisabled: {
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
helperText: {
|
|
||||||
color: theme.colors.mutedForeground,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
},
|
|
||||||
testButton: {
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: theme.colors.palette.blue[600],
|
|
||||||
},
|
|
||||||
testButtonDisabled: {
|
|
||||||
backgroundColor: theme.colors.muted,
|
|
||||||
},
|
|
||||||
testButtonText: {
|
|
||||||
color: theme.colors.palette.white,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
marginLeft: theme.spacing[2],
|
|
||||||
},
|
|
||||||
testResultSuccess: {
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
backgroundColor: theme.colors.palette.green[900],
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.palette.green[600],
|
|
||||||
},
|
|
||||||
testResultError: {
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
backgroundColor: theme.colors.palette.red[900],
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.palette.red[600],
|
|
||||||
},
|
|
||||||
testResultTextSuccess: {
|
|
||||||
color: theme.colors.palette.green[200],
|
|
||||||
},
|
|
||||||
testResultTextError: {
|
|
||||||
color: theme.colors.palette.red[200],
|
|
||||||
},
|
|
||||||
settingCard: {
|
settingCard: {
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -350,53 +280,16 @@ export default function SettingsScreen() {
|
|||||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||||
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry();
|
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry();
|
||||||
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||||
const [selectedDaemonId, setSelectedDaemonId] = useState<string | null>(null);
|
|
||||||
const selectedDaemon = useMemo(() => {
|
|
||||||
if (!selectedDaemonId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return daemons.find((daemon) => daemon.id === selectedDaemonId) ?? null;
|
|
||||||
}, [daemons, selectedDaemonId]);
|
|
||||||
const selectedDaemonSession = useSessionForServer(selectedDaemonId);
|
|
||||||
const selectedDaemonConnection = selectedDaemonId ? connectionStates.get(selectedDaemonId) : null;
|
|
||||||
const selectedDaemonStatusLabel = selectedDaemonConnection ? formatConnectionStatus(selectedDaemonConnection.status) : null;
|
|
||||||
|
|
||||||
const [serverUrl, setServerUrl] = useState(selectedDaemon?.wsUrl ?? "");
|
|
||||||
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
||||||
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
|
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
|
||||||
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
|
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
|
||||||
const [testResult, setTestResult] = useState<{
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
||||||
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" });
|
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" });
|
||||||
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
|
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
|
||||||
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
|
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
|
||||||
const isLoading = settingsLoading || daemonLoading;
|
const isLoading = settingsLoading || daemonLoading;
|
||||||
const baselineServerUrl = selectedDaemon?.wsUrl ?? "";
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const isServerConfigLocked = Boolean(selectedDaemon && !selectedDaemonSession);
|
|
||||||
const serverDescriptionText = selectedDaemon
|
|
||||||
? `${selectedDaemon.label}${selectedDaemonStatusLabel ? ` - ${selectedDaemonStatusLabel}` : ""}${
|
|
||||||
isServerConfigLocked ? " - Session unavailable" : ""
|
|
||||||
}`
|
|
||||||
: "Add a host to configure its server URL.";
|
|
||||||
const serverHelperText = isServerConfigLocked
|
|
||||||
? `Connect to ${selectedDaemon?.label ?? "this host"} to edit its server URL.`
|
|
||||||
: "Must be a valid WebSocket URL (ws:// or wss://)";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (daemons.length === 0) {
|
|
||||||
setSelectedDaemonId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedDaemonId || !daemons.some((daemon) => daemon.id === selectedDaemonId)) {
|
|
||||||
setSelectedDaemonId(daemons[0].id);
|
|
||||||
}
|
|
||||||
}, [daemons, selectedDaemonId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -510,10 +403,8 @@ export default function SettingsScreen() {
|
|||||||
};
|
};
|
||||||
if (daemonForm.id) {
|
if (daemonForm.id) {
|
||||||
await updateDaemon(daemonForm.id, payload);
|
await updateDaemon(daemonForm.id, payload);
|
||||||
setSelectedDaemonId(daemonForm.id);
|
|
||||||
} else {
|
} else {
|
||||||
const created = await addDaemon(payload);
|
await addDaemon(payload);
|
||||||
setSelectedDaemonId(created.id);
|
|
||||||
}
|
}
|
||||||
handleCloseDaemonForm();
|
handleCloseDaemonForm();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -586,19 +477,14 @@ export default function SettingsScreen() {
|
|||||||
setTheme(settings.theme);
|
setTheme(settings.theme);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setServerUrl(selectedDaemon?.wsUrl ?? "");
|
|
||||||
}, [selectedDaemon?.wsUrl]);
|
|
||||||
|
|
||||||
// Track changes
|
// Track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const changed =
|
const changed =
|
||||||
serverUrl !== baselineServerUrl ||
|
|
||||||
useSpeaker !== settings.useSpeaker ||
|
useSpeaker !== settings.useSpeaker ||
|
||||||
keepScreenOn !== settings.keepScreenOn ||
|
keepScreenOn !== settings.keepScreenOn ||
|
||||||
theme !== settings.theme;
|
theme !== settings.theme;
|
||||||
setHasChanges(changed);
|
setHasChanges(changed);
|
||||||
}, [serverUrl, baselineServerUrl, useSpeaker, keepScreenOn, theme, settings]);
|
}, [useSpeaker, keepScreenOn, theme, settings]);
|
||||||
|
|
||||||
function validateServerUrl(url: string): boolean {
|
function validateServerUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -609,49 +495,14 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveDaemonLabel(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.hostname || "Host";
|
|
||||||
} catch {
|
|
||||||
return "Host";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
// Validate server URL
|
|
||||||
if (!validateServerUrl(serverUrl)) {
|
|
||||||
Alert.alert(
|
|
||||||
"Invalid URL",
|
|
||||||
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
|
|
||||||
[{ text: "OK" }]
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trimmedUrl = serverUrl.trim();
|
|
||||||
|
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
useSpeaker,
|
useSpeaker,
|
||||||
keepScreenOn,
|
keepScreenOn,
|
||||||
theme,
|
theme,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedDaemon) {
|
|
||||||
await updateDaemon(selectedDaemon.id, {
|
|
||||||
wsUrl: trimmedUrl,
|
|
||||||
label: selectedDaemon.label || deriveDaemonLabel(trimmedUrl),
|
|
||||||
});
|
|
||||||
setSelectedDaemonId(selectedDaemon.id);
|
|
||||||
} else {
|
|
||||||
const created = await addDaemon({
|
|
||||||
label: deriveDaemonLabel(trimmedUrl),
|
|
||||||
wsUrl: trimmedUrl,
|
|
||||||
});
|
|
||||||
setSelectedDaemonId(created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Settings Saved",
|
"Settings Saved",
|
||||||
"Your settings have been saved successfully.",
|
"Your settings have been saved successfully.",
|
||||||
@@ -700,48 +551,6 @@ export default function SettingsScreen() {
|
|||||||
const restartConfirmationMessage =
|
const restartConfirmationMessage =
|
||||||
"This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts.";
|
"This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts.";
|
||||||
|
|
||||||
|
|
||||||
async function handleTestConnection() {
|
|
||||||
if (isServerConfigLocked) {
|
|
||||||
Alert.alert(
|
|
||||||
"Session unavailable",
|
|
||||||
`${selectedDaemon?.label ?? "This host"} is not connected. Connect to it before testing the URL.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateServerUrl(serverUrl)) {
|
|
||||||
Alert.alert(
|
|
||||||
"Invalid URL",
|
|
||||||
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
|
|
||||||
[{ text: "OK" }]
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsTesting(true);
|
|
||||||
setTestResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await testServerConnection(serverUrl);
|
|
||||||
setTestResult({
|
|
||||||
success: true,
|
|
||||||
message: "Connection successful",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Connection failed - check URL and network";
|
|
||||||
setTestResult({
|
|
||||||
success: false,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsTesting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
@@ -756,94 +565,6 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<ScrollView style={styles.scrollView}>
|
<ScrollView style={styles.scrollView}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* Server Configuration */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Server Configuration</Text>
|
|
||||||
{daemons.length > 0 ? (
|
|
||||||
<View style={styles.hostSelectorRow}>
|
|
||||||
{daemons.map((daemon) => {
|
|
||||||
const isSelected = daemon.id === selectedDaemonId;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={daemon.id}
|
|
||||||
style={[styles.hostSelectorChip, isSelected && styles.hostSelectorChipSelected]}
|
|
||||||
onPress={() => setSelectedDaemonId(daemon.id)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[styles.hostSelectorChipText, isSelected && styles.hostSelectorChipTextSelected]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{daemon.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<Text style={styles.helperText}>{serverDescriptionText}</Text>
|
|
||||||
|
|
||||||
<Text style={styles.label}>WebSocket URL</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.input, isServerConfigLocked && styles.inputDisabled]}
|
|
||||||
placeholder="wss://example.com/ws"
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
value={serverUrl}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setServerUrl(text);
|
|
||||||
setTestResult(null);
|
|
||||||
}}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
editable={!isServerConfigLocked}
|
|
||||||
selectTextOnFocus={!isServerConfigLocked}
|
|
||||||
/>
|
|
||||||
<Text style={styles.helperText}>
|
|
||||||
{serverHelperText}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Test Connection Button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleTestConnection}
|
|
||||||
disabled={isTesting || !validateServerUrl(serverUrl)}
|
|
||||||
style={[
|
|
||||||
styles.testButton,
|
|
||||||
(isTesting || !validateServerUrl(serverUrl) || isServerConfigLocked) &&
|
|
||||||
styles.testButtonDisabled,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isTesting ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
|
||||||
<Text style={styles.testButtonText}>Testing...</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.testButtonText}>Test Connection</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Test Result */}
|
|
||||||
{testResult && (
|
|
||||||
<View
|
|
||||||
style={
|
|
||||||
testResult.success
|
|
||||||
? styles.testResultSuccess
|
|
||||||
: styles.testResultError
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={
|
|
||||||
testResult.success
|
|
||||||
? styles.testResultTextSuccess
|
|
||||||
: styles.testResultTextError
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{testResult.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Host Management */}
|
{/* Host Management */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Hosts</Text>
|
<Text style={styles.sectionTitle}>Hosts</Text>
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope
|
|||||||
- Create/import modal now requires explicit host selection (no first entry default) and `useAggregatedAgents` no longer fabricates a host id via `connectionStates.keys().next()`; verified with `npm run typecheck --workspace=@paseo/app`.
|
- Create/import modal now requires explicit host selection (no first entry default) and `useAggregatedAgents` no longer fabricates a host id via `connectionStates.keys().next()`; verified with `npm run typecheck --workspace=@paseo/app`.
|
||||||
|
|
||||||
### 3. Simplify Settings Screen
|
### 3. Simplify Settings Screen
|
||||||
- [ ] Remove the standalone "Test Connection" form/URL input at the top of settings.
|
- [x] Remove the standalone "Test Connection" form/URL input at the top of settings.
|
||||||
|
- Removed the global host selector/Test UI from `packages/app/src/app/settings.tsx` so configuration happens per-host within their cards/forms, and ran `npm run typecheck --workspace=@paseo/app`.
|
||||||
- [ ] Keep the per-host "Test" button in each host row (already exists).
|
- [ ] Keep the per-host "Test" button in each host row (already exists).
|
||||||
|
|
||||||
### 4. Transparent Connection Management
|
### 4. Transparent Connection Management
|
||||||
@@ -52,9 +53,9 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope
|
|||||||
- [ ] Only show errors when the user tries to interact with an agent whose host is stopped.
|
- [ ] Only show errors when the user tries to interact with an agent whose host is stopped.
|
||||||
- [ ] Update connection banners/indicators to show neutral "offline" state instead of error styling.
|
- [ ] Update connection banners/indicators to show neutral "offline" state instead of error styling.
|
||||||
- [ ] Make the Git Diff offline/unavailable state neutral and stop instructing users to "connect" manually.
|
- [ ] Make the Git Diff offline/unavailable state neutral and stop instructing users to "connect" manually.
|
||||||
- Context: `packages/app/src/app/git-diff.tsx:233-241` still renders the destructive error container and tells users "Connect this host or switch to another one to continue," violating the auto-connect guidance.
|
- Context (review): `packages/app/src/app/git-diff.tsx:229-242` still renders the destructive error container and tells users "Connect this host or switch to another one to continue," so the UI still contradicts the auto-connect guidance even after the wording change.
|
||||||
- [ ] Update the File Explorer offline state to match the new philosophy (neutral messaging, no manual connect CTA).
|
- [ ] Update the File Explorer offline state to match the new philosophy (neutral messaging, no manual connect CTA).
|
||||||
- Context: `packages/app/src/app/file-explorer.tsx:690-698` presents the offline host as an error and asks users to "Connect this host and try again."
|
- Context (review): `packages/app/src/app/file-explorer.tsx:690-699` still uses the destructive error styling and the "Connect this host and try again" CTA instead of the neutral offline messaging the new philosophy calls for.
|
||||||
- [ ] Audit the agent detail flows for the same issue (offline agent screen + delete sheet) and replace the "connect this host" requirement with passive/offline messaging.
|
- [ ] Audit the agent detail flows for the same issue (offline agent screen + delete sheet) and replace the "connect this host" requirement with passive/offline messaging.
|
||||||
- Context: `packages/app/src/app/agent/[serverId]/[agentId].tsx:651-660` and `packages/app/src/components/agent-list.tsx:149` continue to show destructive states directing users to manually connect before managing agents.
|
- Context: `packages/app/src/app/agent/[serverId]/[agentId].tsx:651-660` and `packages/app/src/components/agent-list.tsx:149` continue to show destructive states directing users to manually connect before managing agents.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user