feat: redesign settings pages to match canonical flat UI patterns (#493)
* refactor(settings): new IA shell + projects section + skills catalog discoverability * chore(settings): split providers list by scope; show user before project * fix: navigation flow in mobile Settings * feat: redesign settings pages to use modern elevated surface patterns * feat: replace helper text with tooltips in settings * ui: redesign update dialog and fix external link routing - Restructures UpdateDialog to focus on changelog readability with a wider max-w-4xl canvas - Highlights @username contributor mentions with theme primary color - Strips excessive vertical padding and right-aligns compact action buttons - Disables streamdown's internal link safety dialog in favor of direct Tauri shell routing * feat: refactor Git identities into dedicated Git settings page * feat: unify sidebar background styling across VS Code and web/mobile * fix: adjust button styling and layout for mobile settings pages * feat: add MCP settings page and sidebar * feat: hide models in provider view (thanks to @nguyenngothuong) * feat: add "Add new provider" option to model selector dropdown * fix: local evroc logo + provider dropdown icons * fix: increase width of provider menu * fix: dark theme background color for better contrast * feat: update @opencode-ai/sdk dependency to v1.2.10 * fix: restore session sorting to only use updated time * fix: added settings for sessions deletion dialog * fix: adjust padding on settings pages for better layout * fix: standardize select dropdown height across UI * fix: agent selector UI and notification settings * fix: remove redundant helper text from settings pages * fix: update UI layout for description fields * fix: remove border-none and shadow-none from textarea classes * fix: enable context menu on sidebar items * feat: refactor UI controls and layout patterns across settings pages * fix: use headerless blocks when page title already provides context * fix: remove subtask option from command settings * fix: refactor mcp page settings * fix: reduce spacing in skills configuration pages * feat: refactor voice settings * feat: refactor settings sidebar sections
This commit is contained in:
committed by
GitHub
parent
d2d39c48ac
commit
d2358c2c03
@@ -511,6 +511,7 @@ export interface SettingsPayload {
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
showTextJustificationActivity?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
autoDeleteEnabled?: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DesktopSettings } from '@/lib/desktop';
|
||||
type AppearanceSlice = {
|
||||
showReasoningTraces: boolean;
|
||||
showTextJustificationActivity: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks: boolean;
|
||||
@@ -45,6 +46,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
let previous: AppearanceSlice = {
|
||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||
showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity,
|
||||
showDeletionDialog: useUIStore.getState().showDeletionDialog,
|
||||
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
|
||||
notificationMode: useUIStore.getState().notificationMode,
|
||||
notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks,
|
||||
@@ -92,6 +94,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
const current: AppearanceSlice = {
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
showTextJustificationActivity: state.showTextJustificationActivity,
|
||||
showDeletionDialog: state.showDeletionDialog,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||
@@ -123,6 +126,9 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.showTextJustificationActivity !== previous.showTextJustificationActivity) {
|
||||
diff.showTextJustificationActivity = current.showTextJustificationActivity;
|
||||
}
|
||||
if (current.showDeletionDialog !== previous.showDeletionDialog) {
|
||||
diff.showDeletionDialog = current.showDeletionDialog;
|
||||
}
|
||||
if (current.nativeNotificationsEnabled !== previous.nativeNotificationsEnabled) {
|
||||
diff.nativeNotificationsEnabled = current.nativeNotificationsEnabled;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export type DesktopSettings = {
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
showTextJustificationActivity?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks?: boolean;
|
||||
|
||||
@@ -1806,7 +1806,7 @@ class OpencodeService {
|
||||
}
|
||||
}
|
||||
|
||||
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; template?: string; subtask?: boolean }>> {
|
||||
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; template?: string }>> {
|
||||
try {
|
||||
const response = await this.client.command.list(
|
||||
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
||||
@@ -1818,7 +1818,6 @@ class OpencodeService {
|
||||
agent: cmd.agent as string | undefined,
|
||||
model: cmd.model as string | undefined,
|
||||
template: cmd.template as string | undefined,
|
||||
subtask: cmd.subtask as boolean | undefined
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -238,6 +238,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.showTextJustificationActivity === 'boolean' && settings.showTextJustificationActivity !== store.showTextJustificationActivity) {
|
||||
store.setShowTextJustificationActivity(settings.showTextJustificationActivity);
|
||||
}
|
||||
if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) {
|
||||
store.setShowDeletionDialog(settings.showDeletionDialog);
|
||||
}
|
||||
if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) {
|
||||
store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled);
|
||||
}
|
||||
@@ -426,6 +429,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.showTextJustificationActivity === 'boolean') {
|
||||
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
|
||||
}
|
||||
if (typeof candidate.showDeletionDialog === 'boolean') {
|
||||
result.showDeletionDialog = candidate.showDeletionDialog;
|
||||
}
|
||||
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
|
||||
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import {
|
||||
type RouteState,
|
||||
@@ -17,7 +16,7 @@ export function parseRoute(searchParams?: URLSearchParams): RouteState {
|
||||
return {
|
||||
sessionId: parseSessionId(params),
|
||||
tab: parseTab(params),
|
||||
settingsSection: parseSettingsSection(params),
|
||||
settingsPath: parseSettingsPath(params),
|
||||
diffFile: parseDiffFile(params),
|
||||
};
|
||||
}
|
||||
@@ -68,10 +67,10 @@ function parseTab(params: URLSearchParams): MainTab | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse settings section from URL parameters.
|
||||
* Returns null if missing or invalid.
|
||||
* Parse settings target from URL parameters.
|
||||
* Returns null if missing/empty.
|
||||
*/
|
||||
function parseSettingsSection(params: URLSearchParams): SidebarSection | null {
|
||||
function parseSettingsPath(params: URLSearchParams): string | null {
|
||||
const value = params.get(ROUTE_PARAMS.SETTINGS);
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -79,17 +78,21 @@ function parseSettingsSection(params: URLSearchParams): SidebarSection | null {
|
||||
|
||||
const normalized = value.toLowerCase().trim();
|
||||
|
||||
// Check if it's a valid section
|
||||
if ((VALID_SETTINGS_SECTIONS as readonly string[]).includes(normalized)) {
|
||||
return normalized as SidebarSection;
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle common aliases
|
||||
if (normalized === 'openchamber' || normalized === 'general' || normalized === 'preferences') {
|
||||
return 'settings';
|
||||
return 'home';
|
||||
}
|
||||
|
||||
return null;
|
||||
// Keep legacy section ids as-is (mapping happens at apply time).
|
||||
if ((VALID_SETTINGS_SECTIONS as readonly string[]).includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { ROUTE_PARAMS } from './types';
|
||||
|
||||
@@ -9,7 +8,7 @@ export interface AppRouteState {
|
||||
sessionId: string | null;
|
||||
tab: MainTab;
|
||||
isSettingsOpen: boolean;
|
||||
settingsSection: SidebarSection;
|
||||
settingsPath: string;
|
||||
diffFile: string | null;
|
||||
}
|
||||
|
||||
@@ -32,8 +31,8 @@ export function serializeRoute(state: AppRouteState): URLSearchParams {
|
||||
|
||||
// Settings takes precedence - if open, include settings section
|
||||
if (state.isSettingsOpen) {
|
||||
const settingsSection = state.settingsSection === 'sessions' ? 'settings' : state.settingsSection;
|
||||
params.set(ROUTE_PARAMS.SETTINGS, settingsSection);
|
||||
const settingsPath = state.settingsPath.trim().length > 0 ? state.settingsPath : 'home';
|
||||
params.set(ROUTE_PARAMS.SETTINGS, settingsPath);
|
||||
// Don't include tab when settings is open (it's a full-screen overlay)
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface RouteState {
|
||||
/** Main tab to display (chat, git, diff, terminal, files) */
|
||||
tab: MainTab | null;
|
||||
/** Settings section - when non-null, settings dialog should be open */
|
||||
settingsSection: SidebarSection | null;
|
||||
settingsPath: string | null;
|
||||
/** File path for diff view */
|
||||
diffFile: string | null;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export const VALID_SETTINGS_SECTIONS: readonly SidebarSection[] = [
|
||||
'commands',
|
||||
'skills',
|
||||
'providers',
|
||||
'usage',
|
||||
'git-identities',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
|
||||
export type SettingsPageSlug =
|
||||
| 'home'
|
||||
| 'projects'
|
||||
| 'providers'
|
||||
| 'usage'
|
||||
| 'agents'
|
||||
| 'commands'
|
||||
| 'mcp'
|
||||
| 'skills.installed'
|
||||
| 'skills.catalog'
|
||||
| 'git'
|
||||
| 'appearance'
|
||||
| 'chat'
|
||||
| 'shortcuts'
|
||||
| 'sessions'
|
||||
| 'notifications'
|
||||
| 'voice';
|
||||
|
||||
export type SettingsPageGroup =
|
||||
| 'appearance'
|
||||
| 'projects'
|
||||
| 'general'
|
||||
| 'opencode'
|
||||
| 'git'
|
||||
| 'skills'
|
||||
| 'usage'
|
||||
| 'advanced';
|
||||
|
||||
export interface SettingsRuntimeContext {
|
||||
isVSCode: boolean;
|
||||
isWeb: boolean;
|
||||
isDesktop: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsPageMeta {
|
||||
slug: SettingsPageSlug;
|
||||
title: string;
|
||||
group: SettingsPageGroup;
|
||||
kind: 'single' | 'split';
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
isAvailable?: (ctx: SettingsRuntimeContext) => boolean;
|
||||
}
|
||||
|
||||
export const SETTINGS_GROUP_LABELS: Record<SettingsPageGroup, string> = {
|
||||
appearance: 'Appearance',
|
||||
projects: 'Projects',
|
||||
general: 'General',
|
||||
opencode: 'OpenCode',
|
||||
git: 'Git',
|
||||
skills: 'Skills',
|
||||
usage: 'Usage',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
{
|
||||
slug: 'home',
|
||||
title: 'Settings',
|
||||
group: 'general',
|
||||
kind: 'single',
|
||||
description: 'Search and jump to common pages.',
|
||||
keywords: ['search', 'settings'],
|
||||
},
|
||||
{
|
||||
slug: 'projects',
|
||||
title: 'Projects',
|
||||
group: 'projects',
|
||||
kind: 'split',
|
||||
keywords: ['project', 'projects', 'worktree', 'worktrees', 'repo', 'repository', 'directory'],
|
||||
},
|
||||
{
|
||||
slug: 'providers',
|
||||
title: 'Providers',
|
||||
group: 'opencode',
|
||||
kind: 'split',
|
||||
keywords: ['provider', 'providers', 'models', 'model', 'api key', 'api keys', 'openai', 'anthropic', 'ollama', 'credentials'],
|
||||
},
|
||||
{
|
||||
slug: 'usage',
|
||||
title: 'Usage',
|
||||
group: 'usage',
|
||||
kind: 'split',
|
||||
keywords: ['quota', 'billing', 'tokens', 'usage', 'limits'],
|
||||
},
|
||||
{
|
||||
slug: 'agents',
|
||||
title: 'Agents',
|
||||
group: 'opencode',
|
||||
kind: 'split',
|
||||
keywords: ['agent', 'agents', 'prompts', 'tools', 'permissions'],
|
||||
},
|
||||
{
|
||||
slug: 'commands',
|
||||
title: 'Commands',
|
||||
group: 'opencode',
|
||||
kind: 'split',
|
||||
keywords: ['command', 'commands', 'slash', 'macros', 'automation'],
|
||||
},
|
||||
{
|
||||
slug: 'mcp',
|
||||
title: 'MCP',
|
||||
group: 'opencode',
|
||||
kind: 'split',
|
||||
keywords: ['mcp', 'model context protocol', 'servers', 'tools', 'remote', 'stdio'],
|
||||
},
|
||||
{
|
||||
slug: 'skills.installed',
|
||||
title: 'Skills',
|
||||
group: 'skills',
|
||||
kind: 'split',
|
||||
keywords: ['skill', 'skills', 'instructions', 'install', 'catalog'],
|
||||
},
|
||||
{
|
||||
slug: 'skills.catalog',
|
||||
title: 'Skills Catalog',
|
||||
group: 'skills',
|
||||
kind: 'single',
|
||||
keywords: ['install', 'catalog', 'external', 'repository', 'skills catalog'],
|
||||
},
|
||||
{
|
||||
slug: 'git',
|
||||
title: 'Git',
|
||||
group: 'git',
|
||||
kind: 'single',
|
||||
keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
slug: 'appearance',
|
||||
title: 'Appearance',
|
||||
group: 'appearance',
|
||||
kind: 'single',
|
||||
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal'],
|
||||
},
|
||||
{
|
||||
slug: 'chat',
|
||||
title: 'Chat',
|
||||
group: 'general',
|
||||
kind: 'single',
|
||||
keywords: ['tools', 'diff', 'reasoning', 'dotfiles', 'draft', 'queue', 'output'],
|
||||
},
|
||||
{
|
||||
slug: 'shortcuts',
|
||||
title: 'Shortcuts',
|
||||
group: 'general',
|
||||
kind: 'single',
|
||||
keywords: ['keyboard', 'hotkeys', 'shortcuts', 'bindings'],
|
||||
},
|
||||
{
|
||||
slug: 'sessions',
|
||||
title: 'Sessions',
|
||||
group: 'general',
|
||||
kind: 'single',
|
||||
keywords: ['defaults', 'default agent', 'default model', 'retention', 'memory', 'limits', 'zen'],
|
||||
},
|
||||
|
||||
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
|
||||
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
] as const;
|
||||
|
||||
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||
sessions: 'sessions',
|
||||
agents: 'agents',
|
||||
commands: 'commands',
|
||||
mcp: 'mcp',
|
||||
skills: 'skills.installed',
|
||||
providers: 'providers',
|
||||
usage: 'usage',
|
||||
'git-identities': 'git',
|
||||
settings: 'home',
|
||||
};
|
||||
|
||||
export function getSettingsPageMeta(slug: string): SettingsPageMeta | null {
|
||||
const normalized = slug.trim().toLowerCase();
|
||||
return (SETTINGS_PAGE_METADATA as readonly SettingsPageMeta[]).find((page) => page.slug === normalized) ?? null;
|
||||
}
|
||||
|
||||
export function resolveSettingsSlug(value: string | null | undefined): SettingsPageSlug {
|
||||
const normalized = (value ?? '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return 'home';
|
||||
}
|
||||
|
||||
const legacy = (LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG as Record<string, SettingsPageSlug>)[normalized];
|
||||
if (legacy) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
const direct = getSettingsPageMeta(normalized);
|
||||
if (direct) {
|
||||
return direct.slug;
|
||||
}
|
||||
|
||||
return 'home';
|
||||
}
|
||||
@@ -9,15 +9,15 @@
|
||||
},
|
||||
"colors": {
|
||||
"primary": {
|
||||
"base": "#EC8B49",
|
||||
"base": "#DA702C",
|
||||
"hover": "#DA702C",
|
||||
"active": "#F9AE77",
|
||||
"foreground": "#100F0F",
|
||||
"muted": "#EC8B4980",
|
||||
"foreground": "#111010",
|
||||
"muted": "#DA702C80",
|
||||
"emphasis": "#F9AE77"
|
||||
},
|
||||
"surface": {
|
||||
"background": "#100F0F",
|
||||
"background": "#111010",
|
||||
"foreground": "#CECDC3",
|
||||
"muted": "#1C1B1A",
|
||||
"mutedForeground": "#878580",
|
||||
@@ -29,30 +29,30 @@
|
||||
"interactive": {
|
||||
"border": "#343331",
|
||||
"borderHover": "#403E3C",
|
||||
"borderFocus": "#EC8B49",
|
||||
"borderFocus": "#DA702C",
|
||||
"selection": "#f4f4f41f",
|
||||
"selectionForeground": "#CECDC3",
|
||||
"focus": "#EC8B49",
|
||||
"focusRing": "#EC8B4950",
|
||||
"focus": "#DA702C",
|
||||
"focusRing": "#DA702C50",
|
||||
"cursor": "#CECDC3",
|
||||
"hover": "#ffffff18",
|
||||
"active": "#ffffff1f"
|
||||
},
|
||||
"status": {
|
||||
"error": "#D14D41",
|
||||
"errorForeground": "#100F0F",
|
||||
"errorForeground": "#111010",
|
||||
"errorBackground": "#AF302920",
|
||||
"errorBorder": "#AF302950",
|
||||
"warning": "#DA702C",
|
||||
"warningForeground": "#100F0F",
|
||||
"warningForeground": "#111010",
|
||||
"warningBackground": "#BC521520",
|
||||
"warningBorder": "#BC521550",
|
||||
"success": "#A0AF54",
|
||||
"successForeground": "#100F0F",
|
||||
"successForeground": "#111010",
|
||||
"successBackground": "#66800B20",
|
||||
"successBorder": "#66800B50",
|
||||
"info": "#4385BE",
|
||||
"infoForeground": "#100F0F",
|
||||
"infoForeground": "#111010",
|
||||
"infoBackground": "#205EA620",
|
||||
"infoBorder": "#205EA650"
|
||||
},
|
||||
@@ -138,9 +138,9 @@
|
||||
},
|
||||
"chat": {
|
||||
"userMessage": "#CECDC3",
|
||||
"userMessageBackground": "#2d1d15",
|
||||
"userMessageBackground": "#2E1B10",
|
||||
"assistantMessage": "#CECDC3",
|
||||
"assistantMessageBackground": "#100F0F",
|
||||
"assistantMessageBackground": "#111010",
|
||||
"timestamp": "#878580",
|
||||
"divider": "#343331"
|
||||
},
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
},
|
||||
"colors": {
|
||||
"primary": {
|
||||
"base": "#EC8B49",
|
||||
"base": "#BC5215",
|
||||
"hover": "#F9AE77",
|
||||
"active": "#DA702C",
|
||||
"foreground": "#FFFCF0",
|
||||
"muted": "#EC8B4980",
|
||||
"muted": "#BC521580",
|
||||
"emphasis": "#F9AE77"
|
||||
},
|
||||
"surface": {
|
||||
@@ -24,16 +24,16 @@
|
||||
"elevated": "#fdf6ec",
|
||||
"elevatedForeground": "#100F0F",
|
||||
"overlay": "#100F0F20",
|
||||
"subtle": "#f9f1e4"
|
||||
"subtle": "#F6F0E6"
|
||||
},
|
||||
"interactive": {
|
||||
"border": "#DAD8CE",
|
||||
"borderHover": "#CECDC3",
|
||||
"borderFocus": "#EC8B49",
|
||||
"borderFocus": "#BC5215",
|
||||
"selection": "#76736f30",
|
||||
"selectionForeground": "#100F0F",
|
||||
"focus": "#EC8B49",
|
||||
"focusRing": "#EC8B4940",
|
||||
"focus": "#BC5215",
|
||||
"focusRing": "#BC521540",
|
||||
"cursor": "#100F0F",
|
||||
"hover": "#76736f20",
|
||||
"active": "#76736f20"
|
||||
@@ -138,7 +138,7 @@
|
||||
},
|
||||
"chat": {
|
||||
"userMessage": "#100F0F",
|
||||
"userMessageBackground": "#f9f1e4",
|
||||
"userMessageBackground": "#F6F0E6",
|
||||
"assistantMessage": "#100F0F",
|
||||
"assistantMessageBackground": "#FFFCF0",
|
||||
"timestamp": "#6F6E69",
|
||||
|
||||
Reference in New Issue
Block a user