feat: 公共 API 基座化(v0.12.0,纯增量)— 类型/能力/设置 seam 全面补齐
- 类型生态:service 模块 re-export SidebarTab/SidebarState/SidebarStore/ SidebarSnapshot/SidebarDiffRef/TabType/SessionScope/SidebarPrefs 等公共 状态词汇(此前 TS2459 无法命名);context-types 去 Node 依赖(结构镜像 SidebarHttpRequest/Response/UpgradeSocket/Head),client 可达声明图零 node:* 引用,外部消费者无 @types/node + skipLibCheck:false 可编译 (scripts/check-consumer-types.sh 守护) - 服务能力:version/features 版本与能力探测(单调只增)、getSnapshot/ subscribeState 状态订阅、updateTab(title/path/meta)、activateTab、 openFile(路径派生 id 与内置拦截一致)、openTab(seed, scope?) 定向到 指定 session(store.reduceFor 不扰动 UI) - TabDescriptor:badge 角标(99+ 封顶,抛错吞掉)、onOpen/onActivate/ onClose 生命周期回调(service 路径派发,dedupe 聚焦触发 onActivate 而非 onOpen,抛错不打断流程);SidebarTab.meta JSON 可序列化跨刷新 持久化(sanitize 宽松保留) - 设置 seam 开放:PrefsSchema 增 pluginSettings 开放嵌套 map(z.dict), settings.pluginToggles(插件自有行,同 switch/text/number 控件)与 settings.render(自定义面板,含 store/service/prefs/updatePluginSetting/ close props);viewer 卡片 v0.12.0 起也有齿轮设置弹窗 - FileViewerDescriptor.load 增第三参 AbortSignal(EditorHost 生命周期中止) - UI 接线:tab 激活/关闭改走 service(跨树激活修复 + 回调触发)、+ 菜单 传 scope、TabBar badge pill 渲染;active-session scope 走原 reduce 路径 保证 notify - 测试 +23:生命周期/定向打开/updateTab/activateTab/openFile/meta 持久化/ reduceFor/能力常量(与 package.json 锁步)、consumer-types.ts 编译门、 api-surface.spec.ts(client 图零 node 依赖守护);既有 430 测试全绿 - 文档:AGENTS.md 修 available 三参示例并补全部新 API;external-plugin- guide.md 刷新至 v0.12.0(7 tab + 9 viewer、设置 seam 开放、类型导入 路径建议);README×2 服务化小节同步 未发版 npm(用户决定);分支 feat/plugin-api-v012。
This commit is contained in:
+268
-12
@@ -22,11 +22,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
|
||||
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/**
|
||||
* Public state vocabulary re-exported for consumers (type-only; the values
|
||||
* stay internal). External plugins name these types in their descriptors —
|
||||
* e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
|
||||
* or `badge: (…, state: SidebarState) => …`.
|
||||
*/
|
||||
export type {
|
||||
SidebarTab,
|
||||
SidebarState,
|
||||
SidebarStore,
|
||||
SidebarSnapshot,
|
||||
SidebarDiffRef,
|
||||
TabType,
|
||||
} from './state.ts'
|
||||
export type { SessionScope } from './api.ts'
|
||||
export type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** The row control a declarative setting renders as in the settings popup. */
|
||||
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
@@ -56,6 +75,19 @@ export interface SidebarSettingToggle {
|
||||
unit?: string
|
||||
}
|
||||
|
||||
/** Props of a descriptor's custom settings panel (`settings.render`). */
|
||||
export interface SidebarSettingsRenderProps {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
/** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
|
||||
pluginSettings: Record<string, unknown>
|
||||
/** Persist one plugin-owned setting of this descriptor. */
|
||||
updatePluginSetting(key: string, value: unknown): void
|
||||
/** Close the settings popup. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
@@ -66,6 +98,21 @@ export interface SidebarSettingsDeclaration {
|
||||
* by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
/**
|
||||
* Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
|
||||
* (switch/text/number), but the keys are plugin-local and persisted in
|
||||
* the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
|
||||
* — no host PrefsSchema field needed. Values must be JSON-serializable
|
||||
* (the row controls produce strings / numbers / booleans).
|
||||
*/
|
||||
pluginToggles?: readonly SidebarSettingToggle[]
|
||||
/**
|
||||
* Custom settings panel (v0.12.0+): when given, the gear popup renders
|
||||
* this instead of the row lists (`toggles` / `pluginToggles`). Receives
|
||||
* the shared store/service, the live prefs, the descriptor's own
|
||||
* `pluginSettings` blob, and a persistence helper.
|
||||
*/
|
||||
render?: (props: SidebarSettingsRenderProps) => ReactNode
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
@@ -129,6 +176,27 @@ export interface TabDescriptor {
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/**
|
||||
* Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
|
||||
* the icon — a number renders as a count (99+ capped), a string renders
|
||||
* as-is, null/undefined hides the badge. Called on every tab-bar render,
|
||||
* so keep it cheap; a throw is swallowed (no badge shown).
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
|
||||
* `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
|
||||
* focus is NOT an open — it fires `onActivate` instead), `onActivate`
|
||||
* when a tab is focused (dedupe focus, id-safety-net focus, or the
|
||||
* tab-bar activation), `onClose` when a tab is closed through
|
||||
* `closeTab`. Builtin-only flows that mutate state directly (the diff
|
||||
* split placement, agent-terminal reconcile) never touch external tabs
|
||||
* and fire no callbacks. A throwing callback is logged and never breaks
|
||||
* the open/close/activate flow.
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -176,8 +244,9 @@ export interface FileViewerDescriptor {
|
||||
* is consulted before its `exts` (per-descriptor, in priority order).
|
||||
*/
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
|
||||
* teardown / re-match; loaders that ignore it keep working. */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
@@ -186,7 +255,26 @@ export interface FileViewerDescriptor {
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
/** The registry service published as `ctx.betterSidebar`. */
|
||||
/** One `openTab` request. */
|
||||
export interface OpenTabSeed {
|
||||
type: string
|
||||
/** Overrides the descriptor's title when given (the editor tab shows the file name). */
|
||||
title?: string
|
||||
/** A file path (the editor tab's content seed). */
|
||||
path?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
diff?: SidebarTab['diff']
|
||||
/** Explicit tab id (defaults to the type). */
|
||||
id?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
url?: string
|
||||
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry service published as `ctx.betterSidebar`.
|
||||
*/
|
||||
export interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
@@ -217,18 +305,49 @@ export interface BetterSidebarService {
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*
|
||||
* `scope` (v0.12.0+) targets a specific session: when given, the open
|
||||
* lands in THAT session's sidebar state (loading it if it has none yet)
|
||||
* without switching the UI's active session; when absent the open lands
|
||||
* in the currently active session (the pre-0.12 behavior).
|
||||
*
|
||||
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
|
||||
* panel hosting the landing pane is collapsed, it is expanded
|
||||
* automatically (the right panel by default, the bottom panel when the
|
||||
* active pane lives there; on narrow viewports the merged drawer opens).
|
||||
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
|
||||
* the panel behavior is their caller's business.
|
||||
*
|
||||
* Note: `available` gates the + menu's disabled state only — it does NOT
|
||||
* refuse `openTab` (only the settings disable switch does).
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
subscribe(listener: () => void): () => void
|
||||
/** The plugin version this service instance was built from ('0.12.0'). */
|
||||
readonly version: string
|
||||
/**
|
||||
* Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
|
||||
* 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
|
||||
* 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
|
||||
* gate new API usage on membership.
|
||||
*/
|
||||
readonly features: readonly string[]
|
||||
/**
|
||||
* The current sidebar snapshot: the active session id, its state (panel
|
||||
* geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
|
||||
* `state`/`sessionId` are undefined until a session becomes active.
|
||||
*/
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** Activate an open tab (the tab-bar activation path; fires descriptor.onActivate). */
|
||||
activateTab(tabId: string): void
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** Extract the lowercase extension without leading dot from a path. */
|
||||
@@ -239,6 +358,50 @@ function extOfPath(path: string): string {
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/** The file name of a path (both separators). */
|
||||
function baseNameOf(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
* are never removed). Each string names a v0.12.0+ capability:
|
||||
* - 'badge': TabDescriptor.badge
|
||||
* - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
|
||||
* - 'updateTab': BetterSidebarService.updateTab
|
||||
* - 'openFile': BetterSidebarService.openFile
|
||||
* - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
|
||||
* - 'stateSubscription': getSnapshot/subscribeState
|
||||
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
|
||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
'tabLifecycle',
|
||||
'updateTab',
|
||||
'openFile',
|
||||
'targetedOpen',
|
||||
'stateSubscription',
|
||||
'tabMeta',
|
||||
'pluginSettings',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
function safeCall(fn: () => void): void {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] plugin callback error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one BetterSidebar service bound to a store. The service owns the
|
||||
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
|
||||
@@ -325,7 +488,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
|
||||
const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
@@ -333,9 +496,18 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return
|
||||
// A scope targets another session: the open lands in THAT session's
|
||||
// state (loaded on demand) without switching the UI's active session.
|
||||
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (targetSessionId === undefined) return
|
||||
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
|
||||
// Lifecycle capture: `created` when the open minted a NEW tab (a
|
||||
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
||||
let created: SidebarTab | undefined
|
||||
let activated: SidebarTab | undefined
|
||||
const reducer = (state: SidebarState): SidebarState => {
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
let next: SidebarState
|
||||
@@ -354,6 +526,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
}
|
||||
@@ -369,6 +542,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
} else {
|
||||
landed = next
|
||||
}
|
||||
// Lifecycle capture (before the auto-expand block, which early-returns).
|
||||
if (!tabOpenIn(state, tab.id)) {
|
||||
created = tab
|
||||
} else {
|
||||
// A focus happened (dedupeKey or the id safety net): resolve the
|
||||
// tab that is actually active now and report THAT to onActivate.
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
activated = key !== undefined
|
||||
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
: candidates.find(candidate => candidate.id === tab.id)
|
||||
activated ??= tab
|
||||
}
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
// viewports the two workbenches merge into one drawer, so the drawer
|
||||
@@ -395,15 +582,77 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
}
|
||||
return landed
|
||||
})
|
||||
}
|
||||
// A scope targeting ANOTHER session lands the open there without
|
||||
// switching the UI; a scope naming the active session (or no scope)
|
||||
// takes the regular reduce path so the UI notifies and re-renders.
|
||||
const activeSessionId = store.getSnapshot().sessionId
|
||||
if (scope !== undefined && scope.sessionId !== activeSessionId) {
|
||||
store.reduceFor(scope.sessionId, reducer)
|
||||
} else {
|
||||
store.reduce(reducer)
|
||||
}
|
||||
if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
|
||||
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
let closed: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
if (closed !== undefined) {
|
||||
const sessionId = store.getSnapshot().sessionId
|
||||
if (sessionId !== undefined) {
|
||||
const descriptor = tabs.get(closed.type)
|
||||
safeCall(() => descriptor?.onClose?.(closed!, { sessionId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The snapshot the store publishes (state/prefs carry the active session). */
|
||||
const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
|
||||
|
||||
/** Store changes: session switch, state mutations, prefs writes. */
|
||||
const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
|
||||
|
||||
/** Patch an open tab's display fields (a missing tab id is a no-op). */
|
||||
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
|
||||
store.reduce((state) => patchTab(state, tabId, {
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
|
||||
const activateTab = (tabId: string): void => {
|
||||
let activated: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return activateTabReducer(state, paneId, tabId)
|
||||
})
|
||||
if (activated !== undefined) {
|
||||
const sessionId = store.getSnapshot().sessionId
|
||||
if (sessionId !== undefined) {
|
||||
const descriptor = tabs.get(activated.type)
|
||||
safeCall(() => descriptor?.onActivate?.(activated!, { sessionId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults
|
||||
* to the file name; the tab id is path-derived, like the internal
|
||||
* open-path interception, so distinct files open side by side). */
|
||||
const openFile = (scope: SessionScope, path: string, title?: string): void => {
|
||||
openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -418,6 +667,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
version: SIDEBAR_SERVICE_VERSION,
|
||||
features: SIDEBAR_FEATURES,
|
||||
getSnapshot,
|
||||
subscribeState,
|
||||
updateTab,
|
||||
activateTab,
|
||||
openFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +692,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
|
||||
// bottom panel focuses an existing instance wherever it lives.
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
|
||||
Reference in New Issue
Block a user