mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-31 14:33:18 +02:00
Add command palette (#46)
This PR finished the initial PoC command palette. It currently only supports switching between requests and workspaces, but can easily be extended for more.
This commit is contained in:
@@ -1,28 +1,86 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactNode } from 'react';
|
import type { KeyboardEvent, ReactNode } from 'react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
||||||
|
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||||
|
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||||
import { useRequests } from '../hooks/useRequests';
|
import { useRequests } from '../hooks/useRequests';
|
||||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||||
import { Input } from './core/Input';
|
import { Heading } from './core/Heading';
|
||||||
|
import { Icon } from './core/Icon';
|
||||||
|
import { PlainInput } from './core/PlainInput';
|
||||||
|
|
||||||
|
interface CommandPaletteGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: CommandPaletteItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandPaletteItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||||
const routes = useAppRoutes();
|
const routes = useAppRoutes();
|
||||||
const activeEnvironmentId = useActiveEnvironmentId();
|
const activeEnvironmentId = useActiveEnvironmentId();
|
||||||
const workspaces = useWorkspaces();
|
const workspaces = useWorkspaces();
|
||||||
|
const recentWorkspaces = useRecentWorkspaces();
|
||||||
const requests = useRequests();
|
const requests = useRequests();
|
||||||
|
const recentRequests = useRecentRequests();
|
||||||
const [command, setCommand] = useState<string>('');
|
const [command, setCommand] = useState<string>('');
|
||||||
|
const openWorkspace = useOpenWorkspace();
|
||||||
|
|
||||||
const items = useMemo<{ label: string; onSelect: () => void; key: string }[]>(() => {
|
const sortedRequests = useMemo(() => {
|
||||||
const items = [];
|
return [...requests].sort((a, b) => {
|
||||||
for (const r of requests) {
|
const aRecentIndex = recentRequests.indexOf(a.id);
|
||||||
items.push({
|
const bRecentIndex = recentRequests.indexOf(b.id);
|
||||||
|
|
||||||
|
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||||
|
return aRecentIndex - bRecentIndex;
|
||||||
|
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||||
|
return -1;
|
||||||
|
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [recentRequests, requests]);
|
||||||
|
|
||||||
|
const sortedWorkspaces = useMemo(() => {
|
||||||
|
return [...workspaces].sort((a, b) => {
|
||||||
|
const aRecentIndex = recentWorkspaces.indexOf(a.id);
|
||||||
|
const bRecentIndex = recentWorkspaces.indexOf(b.id);
|
||||||
|
|
||||||
|
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||||
|
return aRecentIndex - bRecentIndex;
|
||||||
|
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||||
|
return -1;
|
||||||
|
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [recentWorkspaces, workspaces]);
|
||||||
|
|
||||||
|
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
||||||
|
const requestGroup: CommandPaletteGroup = {
|
||||||
|
key: 'requests',
|
||||||
|
label: 'Requests',
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const r of sortedRequests.slice(0, 4)) {
|
||||||
|
requestGroup.items.push({
|
||||||
key: `switch-request-${r.id}`,
|
key: `switch-request-${r.id}`,
|
||||||
label: `Switch Request → ${fallbackRequestName(r)}`,
|
label: fallbackRequestName(r),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
return routes.navigate('request', {
|
return routes.navigate('request', {
|
||||||
workspaceId: r.workspaceId,
|
workspaceId: r.workspaceId,
|
||||||
@@ -32,25 +90,34 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const w of workspaces) {
|
|
||||||
items.push({
|
const workspaceGroup: CommandPaletteGroup = {
|
||||||
|
key: 'workspaces',
|
||||||
|
label: 'Workspaces',
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const w of sortedWorkspaces.slice(0, 4)) {
|
||||||
|
workspaceGroup.items.push({
|
||||||
key: `switch-workspace-${w.id}`,
|
key: `switch-workspace-${w.id}`,
|
||||||
label: `Switch Workspace → ${w.name}`,
|
label: w.name,
|
||||||
onSelect: async () => {
|
onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }),
|
||||||
const environmentId = (await getRecentEnvironments(w.id))[0];
|
|
||||||
return routes.navigate('workspace', {
|
|
||||||
workspaceId: w.id,
|
|
||||||
environmentId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
|
||||||
}, [activeEnvironmentId, requests, routes, workspaces]);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
return [requestGroup, workspaceGroup];
|
||||||
return items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase()));
|
}, [activeEnvironmentId, openWorkspace, routes, sortedRequests, sortedWorkspaces]);
|
||||||
}, [command, items]);
|
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
groups
|
||||||
|
.map((g) => {
|
||||||
|
g.items = g.items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase()));
|
||||||
|
return g;
|
||||||
|
})
|
||||||
|
.filter((g) => g.items.length > 0),
|
||||||
|
[command, groups],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectAndClose = useCallback(
|
const handleSelectAndClose = useCallback(
|
||||||
(cb: () => void) => {
|
(cb: () => void) => {
|
||||||
@@ -60,44 +127,71 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
[onClose],
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { allItems, selectedItem } = useMemo(() => {
|
||||||
|
const allItems = filteredGroups.flatMap((g) => g.items);
|
||||||
|
let selectedItem = allItems.find((i) => i.key === selectedItemKey) ?? null;
|
||||||
|
if (selectedItem == null) {
|
||||||
|
selectedItem = allItems[0] ?? null;
|
||||||
|
}
|
||||||
|
return { selectedItem, allItems };
|
||||||
|
}, [filteredGroups, selectedItemKey]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'ArrowDown') {
|
const index = allItems.findIndex((v) => v.key === selectedItem?.key);
|
||||||
setSelectedIndex((prev) => prev + 1);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
||||||
setSelectedIndex((prev) => prev - 1);
|
const next = allItems[index + 1];
|
||||||
|
setSelectedItemKey(next?.key ?? null);
|
||||||
|
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
|
||||||
|
const prev = allItems[index - 1];
|
||||||
|
setSelectedItemKey(prev?.key ?? null);
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
const item = filteredItems[selectedIndex];
|
const selected = allItems[index];
|
||||||
if (item) {
|
setSelectedItemKey(selected?.key ?? null);
|
||||||
handleSelectAndClose(item.onSelect);
|
if (selected) {
|
||||||
|
handleSelectAndClose(selected.onSelect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filteredItems, handleSelectAndClose, selectedIndex],
|
[allItems, handleSelectAndClose, selectedItem?.key],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
<div className="px-2 py-2 w-full">
|
<div className="px-2 py-2 w-full">
|
||||||
<Input
|
<PlainInput
|
||||||
hideLabel
|
hideLabel
|
||||||
|
leftSlot={
|
||||||
|
<div className="h-md w-10 flex justify-center items-center">
|
||||||
|
<Icon icon="search" className="text-fg-subtle" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
name="command"
|
name="command"
|
||||||
label="Command"
|
label="Command"
|
||||||
placeholder="Type a command"
|
placeholder="Search or type a command"
|
||||||
|
className="font-sans !text-base"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
onChange={setCommand}
|
onChange={setCommand}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full px-1.5 overflow-y-auto">
|
<div className="h-full px-1.5 overflow-y-auto pb-1">
|
||||||
{filteredItems.map((v, i) => (
|
{filteredGroups.map((g) => (
|
||||||
<CommandPaletteItem
|
<div key={g.key} className="mb-1.5">
|
||||||
active={i === selectedIndex}
|
<Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
||||||
key={v.key}
|
{g.label}
|
||||||
onClick={() => handleSelectAndClose(v.onSelect)}
|
</Heading>
|
||||||
>
|
{g.items.map((v) => (
|
||||||
{v.label}
|
<CommandPaletteItem
|
||||||
</CommandPaletteItem>
|
active={v.key === selectedItem?.key}
|
||||||
|
key={v.key}
|
||||||
|
onClick={() => handleSelectAndClose(v.onSelect)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</CommandPaletteItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,12 +210,15 @@ function CommandPaletteItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
tabIndex={active ? undefined : -1}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-full h-xs flex items-center rounded px-1.5 text-fg-subtle',
|
'w-full h-sm flex items-center rounded px-1.5',
|
||||||
|
'hover:text-fg',
|
||||||
active && 'bg-background-highlight-secondary text-fg',
|
active && 'bg-background-highlight-secondary text-fg',
|
||||||
|
!active && 'text-fg-subtle',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
<span className="truncate">{children}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ export function Overlay({
|
|||||||
variant === 'default' && 'bg-background-backdrop backdrop-blur-sm',
|
variant === 'default' && 'bg-background-backdrop backdrop-blur-sm',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{children}
|
|
||||||
|
|
||||||
{/* Show draggable region at the top */}
|
{/* Show draggable region at the top */}
|
||||||
{/* TODO: Figure out tauri drag region and also make clickable still */}
|
{/* TODO: Figure out tauri drag region and also make clickable still */}
|
||||||
{variant === 'default' && (
|
{variant === 'default' && (
|
||||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
|
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface DialogProps {
|
|||||||
hideX?: boolean;
|
hideX?: boolean;
|
||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
noScroll?: boolean;
|
noScroll?: boolean;
|
||||||
|
vAlign?: 'top' | 'center';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dialog({
|
export function Dialog({
|
||||||
@@ -31,6 +32,7 @@ export function Dialog({
|
|||||||
hideX,
|
hideX,
|
||||||
noPadding,
|
noPadding,
|
||||||
noScroll,
|
noScroll,
|
||||||
|
vAlign = 'center',
|
||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||||
const descriptionId = useMemo(
|
const descriptionId = useMemo(
|
||||||
@@ -50,7 +52,13 @@ export function Dialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open={open} onClose={onClose} portalName="dialog">
|
<Overlay open={open} onClose={onClose} portalName="dialog">
|
||||||
<div className="x-theme-dialog absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none my-5',
|
||||||
|
vAlign === 'top' && 'justify-start',
|
||||||
|
vAlign === 'center' && 'justify-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
@@ -71,7 +79,7 @@ export function Dialog({
|
|||||||
size === 'md' && 'w-[45rem] max-h-[80vh]',
|
size === 'md' && 'w-[45rem] max-h-[80vh]',
|
||||||
size === 'lg' && 'w-[65rem] max-h-[80vh]',
|
size === 'lg' && 'w-[65rem] max-h-[80vh]',
|
||||||
size === 'full' && 'w-[100vw] h-[100vh]',
|
size === 'full' && 'w-[100vw] h-[100vh]',
|
||||||
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
|
size === 'dynamic' && 'min-w-[20rem] max-w-[80vw] max-h-[80vh]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title ? (
|
{title ? (
|
||||||
|
|||||||
@@ -332,6 +332,20 @@ function getExtensions({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
...baseExtensions, // Must be first
|
...baseExtensions, // Must be first
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
focus: () => {
|
||||||
|
onFocus.current?.();
|
||||||
|
},
|
||||||
|
blur: () => {
|
||||||
|
onBlur.current?.();
|
||||||
|
},
|
||||||
|
keydown: (e) => {
|
||||||
|
onKeyDown.current?.(e);
|
||||||
|
},
|
||||||
|
paste: (e) => {
|
||||||
|
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
|
||||||
|
},
|
||||||
|
}),
|
||||||
tooltips({ parent }),
|
tooltips({ parent }),
|
||||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||||
...(singleLine ? [singleLineExt()] : []),
|
...(singleLine ? [singleLineExt()] : []),
|
||||||
@@ -349,21 +363,6 @@ function getExtensions({
|
|||||||
onChange.current?.(update.state.doc.toString());
|
onChange.current?.(update.state.doc.toString());
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
EditorView.domEventHandlers({
|
|
||||||
focus: () => {
|
|
||||||
onFocus.current?.();
|
|
||||||
},
|
|
||||||
blur: () => {
|
|
||||||
onBlur.current?.();
|
|
||||||
},
|
|
||||||
keydown: (e) => {
|
|
||||||
onKeyDown.current?.(e);
|
|
||||||
},
|
|
||||||
paste: (e) => {
|
|
||||||
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { InputProps } from './Input';
|
|||||||
import { HStack } from './Stacks';
|
import { HStack } from './Stacks';
|
||||||
|
|
||||||
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & {
|
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & {
|
||||||
type: 'text' | 'password' | 'number';
|
type?: 'text' | 'password' | 'number';
|
||||||
step?: number;
|
step?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
|
|||||||
const inputClassName = classNames(
|
const inputClassName = classNames(
|
||||||
className,
|
className,
|
||||||
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
|
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
|
||||||
'px-1.5 text-xs font-mono',
|
'px-1.5 text-xs font-mono cursor-text',
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { CommandPalette } from '../components/CommandPalette';
|
import { CommandPalette } from '../components/CommandPalette';
|
||||||
import { useDialog } from '../components/DialogContext';
|
import { useDialog } from '../components/DialogContext';
|
||||||
import { useAppInfo } from './useAppInfo';
|
|
||||||
import { useHotKey } from './useHotKey';
|
import { useHotKey } from './useHotKey';
|
||||||
|
|
||||||
export function useCommandPalette() {
|
export function useCommandPalette() {
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const appInfo = useAppInfo();
|
|
||||||
useHotKey('command_palette.toggle', () => {
|
useHotKey('command_palette.toggle', () => {
|
||||||
// Disabled in production for now
|
|
||||||
if (!appInfo?.isDev) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.toggle({
|
dialog.toggle({
|
||||||
id: 'command_palette',
|
id: 'command_palette',
|
||||||
size: 'md',
|
size: 'dynamic',
|
||||||
hideX: true,
|
hideX: true,
|
||||||
|
vAlign: 'top',
|
||||||
noPadding: true,
|
noPadding: true,
|
||||||
noScroll: true,
|
noScroll: true,
|
||||||
render: ({ hide }) => <CommandPalette onClose={hide} />,
|
render: ({ hide }) => <CommandPalette onClose={hide} />,
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import type { Workspace } from '../lib/models';
|
|||||||
import { useAppRoutes } from './useAppRoutes';
|
import { useAppRoutes } from './useAppRoutes';
|
||||||
import { getRecentEnvironments } from './useRecentEnvironments';
|
import { getRecentEnvironments } from './useRecentEnvironments';
|
||||||
import { getRecentRequests } from './useRecentRequests';
|
import { getRecentRequests } from './useRecentRequests';
|
||||||
import { useSettings } from './useSettings';
|
|
||||||
|
|
||||||
export function useOpenWorkspace() {
|
export function useOpenWorkspace() {
|
||||||
const settings = useSettings();
|
|
||||||
const routes = useAppRoutes();
|
const routes = useAppRoutes();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -18,7 +16,7 @@ export function useOpenWorkspace() {
|
|||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
inNewWindow: boolean;
|
inNewWindow: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (settings == null || workspace == null) return;
|
if (workspace == null) return;
|
||||||
|
|
||||||
if (inNewWindow) {
|
if (inNewWindow) {
|
||||||
const environmentId = (await getRecentEnvironments(workspace.id))[0];
|
const environmentId = (await getRecentEnvironments(workspace.id))[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user