Merge pull request #209 from Tencent-Hunyuan/fix/memory-map-node-navigation
fix(memhub): open source memories from map nodes
This commit is contained in:
3 files changed
+304
-109
No files matched your search
@@ -5,7 +5,6 @@ import type {
|
||||
MemoryGraphNode,
|
||||
MemoryGraphResponse,
|
||||
MemoryProcessingSummary,
|
||||
SourceImportReceipt as Receipt,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
@@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MemoryMap } from "./MemoryMap";
|
||||
import type { MemoryArrival } from "./memory-arrival-state";
|
||||
|
||||
const { sourceReceipt } = vi.hoisted(() => ({
|
||||
sourceReceipt: vi.fn(),
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
getSourceImportReceipt: sourceReceipt,
|
||||
}));
|
||||
vi.mock("./MemoryGraph3D", () => ({
|
||||
MemoryGraph3D: ({
|
||||
graph,
|
||||
@@ -987,35 +980,6 @@ const importedSources = [
|
||||
imported_conversations: 1,
|
||||
},
|
||||
] as SourceResponse[];
|
||||
const importedReceipt: Receipt = {
|
||||
source_id: "codex",
|
||||
measured_at: "2026-09-14T00:00:00Z",
|
||||
messages: 5,
|
||||
conversations: 2,
|
||||
person_messages: 2,
|
||||
agent_messages: 2,
|
||||
tool_messages: 0,
|
||||
unknown_author_messages: 1,
|
||||
unknown_time_messages: 1,
|
||||
examples: [
|
||||
{
|
||||
evidence_id: "raw-codex",
|
||||
role: "user",
|
||||
written_by: "person",
|
||||
created_at: null,
|
||||
excerpt: "合成来源原文,不是治理记忆。",
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
async function openSourceReceipt() {
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "图中选择 Codex · 2 段" }),
|
||||
);
|
||||
await userEvent.click(screen.getByText("查看已读内容"));
|
||||
await waitFor(() => expect(sourceReceipt).toHaveBeenCalledWith("codex"));
|
||||
}
|
||||
|
||||
it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
|
||||
render(<Host items={[]} sources={importedSources} />);
|
||||
const canvas = screen.getByTestId("canvas");
|
||||
@@ -1030,7 +994,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
|
||||
expect(
|
||||
screen.getByRole("list", { name: "地图记忆列表" }).children,
|
||||
).toHaveLength(0);
|
||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
|
||||
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
|
||||
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
|
||||
expect(canvas.dataset.selected ?? "").toBe("");
|
||||
@@ -1052,83 +1017,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se
|
||||
);
|
||||
await userEvent.type(search, "cOdEx");
|
||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||
expect(screen.getByText("查看已读内容")).toBeTruthy();
|
||||
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||
await userEvent.clear(search);
|
||||
await userEvent.type(search, "Cursor");
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||
await userEvent.clear(search);
|
||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||
});
|
||||
|
||||
it("REV09: source selection uses the actual permission-checked receipt API", async () => {
|
||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
||||
render(<Host items={[]} sources={importedSources} />);
|
||||
await openSourceReceipt();
|
||||
expect(
|
||||
await screen.findByText(importedReceipt.examples[0].excerpt),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("用户 · 时间未知")).toBeTruthy();
|
||||
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe(
|
||||
"5",
|
||||
it("selecting a source node waits for its settled page, then opens the first memory", async () => {
|
||||
const onQuery = vi.fn();
|
||||
const onSource = vi.fn();
|
||||
const resetPage = vi.fn();
|
||||
const oldPage = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(oldPage),
|
||||
query: "旧查询",
|
||||
settledQuery: "旧查询",
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// No raw conversation is read by id on the SDK data plane.
|
||||
expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull();
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
expect(onQuery).toHaveBeenCalledWith("");
|
||||
expect(onSource).toHaveBeenCalledWith("codex");
|
||||
expect(resetPage).toHaveBeenCalledOnce();
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(oldPage),
|
||||
source: "codex",
|
||||
settledSource: "",
|
||||
loading: true,
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("status")
|
||||
.some((node) => node.textContent === "正在读取记忆详情…"),
|
||||
).toBe(true);
|
||||
|
||||
const settledItems = [memories[1], memories[0]];
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
items={settledItems}
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
...oldPage,
|
||||
items: settledItems,
|
||||
source_ids_by_memory: { m1: ["codex"], m0: ["codex"] },
|
||||
}),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
settledQuery: "",
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["revoked", "removed", "revoked-hidden"])(
|
||||
"REV09: %s source drops fallback, preview and a late receipt without reopening on reauthorization",
|
||||
async (state) => {
|
||||
let finish: (value: Receipt) => void = () => {};
|
||||
sourceReceipt.mockImplementation(
|
||||
() =>
|
||||
new Promise<Receipt>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const view = render(<Host items={[]} sources={[importedSources[0]]} />);
|
||||
await openSourceReceipt();
|
||||
view.rerender(
|
||||
<Host
|
||||
items={[]}
|
||||
hidden={state === "revoked-hidden"}
|
||||
sources={
|
||||
state === "removed"
|
||||
? []
|
||||
: [{ ...importedSources[0], authorized: false }]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
await act(async () => finish(importedReceipt));
|
||||
view.rerender(<Host items={[]} sources={[importedSources[0]]} />);
|
||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
||||
expect(screen.queryByText(importedReceipt.examples[0].excerpt)).toBeNull();
|
||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||
expect(sourceReceipt).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it("does not replace a newer memory choice when a source page settles", async () => {
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "图中选择 第二条" }),
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
|
||||
it("REV09: compact preview close keeps source selection for reopening", async () => {
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closing the compact source inspector cancels its pending auto-open", async () => {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query.includes("max-width"),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
||||
render(<Host items={[]} sources={importedSources} />);
|
||||
await openSourceReceipt();
|
||||
await screen.findByText(importedReceipt.examples[0].excerpt);
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "记忆与原文" })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the source selection and offers retry when its page fails", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
failed: true,
|
||||
onRetry,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("alert")
|
||||
.some((node) => node.textContent === "记忆暂时无法读取"),
|
||||
).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("drops a pending source selection when that source is revoked", async () => {
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host
|
||||
simplified
|
||||
sources={[importedSources[0]]}
|
||||
libraryView={pagedView(page)}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "查看选中来源原文" }),
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={[{ ...importedSources[0], authorized: false }]}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
);
|
||||
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={[importedSources[0]]}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
);
|
||||
expect(screen.getByRole("dialog", { name: "记忆与原文" })).toBeTruthy();
|
||||
expect(screen.getByText("查看已读内容")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
|
||||
@@ -1167,5 +1296,4 @@ it.each([
|
||||
render(<Host items={[]} sources={sources} />);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SourceImportReceipt } from "./SourceImportReceipt";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { useMediaPreference } from "./use-media-preference";
|
||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||
@@ -97,6 +96,8 @@ type Props = {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
@@ -217,6 +218,9 @@ export function MemoryMap(props: Props) {
|
||||
}, [submittedQuery]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
||||
const [pendingSourceSelection, setPendingSourceSelection] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [filterNotice, setFilterNotice] = useState("");
|
||||
@@ -367,6 +371,7 @@ export function MemoryMap(props: Props) {
|
||||
// Source revocation also clears a suspended preview, without closing shared memory detail.
|
||||
if (groupNode && !visibleGroupNode) {
|
||||
setGroupNode(null);
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
}
|
||||
}, [groupNode, visibleGroupNode]);
|
||||
@@ -393,6 +398,7 @@ export function MemoryMap(props: Props) {
|
||||
const pick = useCallback(
|
||||
(memoryId: string, scroll = true) => {
|
||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||
setPendingSourceSelection(null);
|
||||
scrollSelection.current = scroll;
|
||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
setGroupKey("");
|
||||
@@ -406,6 +412,32 @@ export function MemoryMap(props: Props) {
|
||||
},
|
||||
[active, filtered, onOpenMemory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!library ||
|
||||
!pendingSourceSelection ||
|
||||
overlaysSuspended ||
|
||||
library.loading ||
|
||||
library.failed ||
|
||||
!selectedSource ||
|
||||
selectedSource.source_id !== pendingSourceSelection ||
|
||||
library.source !== pendingSourceSelection ||
|
||||
(library.settledSource ?? library.source) !== pendingSourceSelection ||
|
||||
(library.settledQuery ?? library.query).trim() ||
|
||||
library.page?.offset !== 0
|
||||
)
|
||||
return;
|
||||
setPendingSourceSelection(null);
|
||||
const first = active[0];
|
||||
if (first) pick(first.memory_id, false);
|
||||
}, [
|
||||
active,
|
||||
library,
|
||||
overlaysSuspended,
|
||||
pendingSourceSelection,
|
||||
pick,
|
||||
selectedSource,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
||||
if (library?.loading && filtered.length === 0) return;
|
||||
@@ -456,6 +488,7 @@ export function MemoryMap(props: Props) {
|
||||
const clear = useCallback(() => {
|
||||
setSelectedId(null);
|
||||
setGroupNode(null);
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
onCloseDetail();
|
||||
}, [onCloseDetail]);
|
||||
@@ -487,6 +520,7 @@ export function MemoryMap(props: Props) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
}
|
||||
function chooseGroup(key: string) {
|
||||
@@ -513,8 +547,17 @@ export function MemoryMap(props: Props) {
|
||||
setAxis("source");
|
||||
setGroupKey(`source:${node.source_id}`);
|
||||
if (library) {
|
||||
setPendingSourceSelection(node.source_id);
|
||||
library.onQuery("");
|
||||
library.onSource(node.source_id);
|
||||
library.resetPage();
|
||||
} else {
|
||||
const first = sourceGroups
|
||||
.find((entry) => entry.key === `source:${node.source_id}`)
|
||||
?.memoryIds.find((memoryId) =>
|
||||
active.some((memory) => memory.memory_id === memoryId),
|
||||
);
|
||||
if (first) pick(first, false);
|
||||
}
|
||||
}
|
||||
if (node.node_type === "topic") {
|
||||
@@ -991,11 +1034,13 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text(
|
||||
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
|
||||
)}
|
||||
aria-label={text("记忆详情")}
|
||||
title={text("查看原文")}
|
||||
onClick={() => setInspectorOpen(true)}
|
||||
onClick={() => {
|
||||
setInspectorOpen(true);
|
||||
if (selectedSource && library)
|
||||
setPendingSourceSelection(selectedSource.source_id);
|
||||
}}
|
||||
>
|
||||
<FileText size={17} />
|
||||
</button>
|
||||
@@ -1130,7 +1175,7 @@ export function MemoryMap(props: Props) {
|
||||
{inspectorDetail
|
||||
? text("记忆详情")
|
||||
: selectedSource
|
||||
? text("来源原文")
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
@@ -1149,13 +1194,33 @@ export function MemoryMap(props: Props) {
|
||||
</header>
|
||||
<div className="map-inspector-body" ref={inspectorBody}>
|
||||
{selectedSource ? (
|
||||
inspectorOpen &&
|
||||
!overlaysSuspended && (
|
||||
<SourceImportReceipt
|
||||
key={selectedSource.source_id}
|
||||
source={selectedSource}
|
||||
/>
|
||||
)
|
||||
<div className="map-inspector-empty map-inspector-loading">
|
||||
{library?.failed &&
|
||||
library.source === selectedSource.source_id ? (
|
||||
<>
|
||||
<p role="alert">{text("记忆暂时无法读取")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={library.onRetry}
|
||||
>
|
||||
{text("重试")}
|
||||
</button>
|
||||
</>
|
||||
) : pendingSourceSelection || library?.loading ? (
|
||||
<>
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取记忆详情…")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{text("还没有已生成的记忆")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
aria-busy={
|
||||
|
||||
@@ -1027,6 +1027,8 @@ export function MemorySpace({
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
|
||||
Reference in new issue
Block a user