Compare commits
5 Commits
03ff3ece7f
...
c17ba298fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17ba298fb | ||
|
|
7d5e25e325 | ||
|
|
f3c160f103 | ||
|
|
407618baf0 | ||
|
|
1637a0e71c |
@ -1159,7 +1159,7 @@ export const PromptInputSubmit = ({
|
|||||||
|
|
||||||
// let Icon = <ArrowUpIcon className="size-4" />;
|
// let Icon = <ArrowUpIcon className="size-4" />;
|
||||||
let Icon = <svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
let Icon = <svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M5.75 14.75V0.75M0.75 5.75L5.75 0.75L10.75 5.75" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M5.75 14.75V0.75M0.75 5.75L5.75 0.75L10.75 5.75" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>;
|
</svg>;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -40,6 +41,7 @@ import { CodeEditor } from "@/components/workspace/code-editor";
|
|||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { MarkdownTable } from "@/components/workspace/messages/markdown-content";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||||
import { useMarkdownDownload } from "@/core/utils/markdown-download";
|
import { useMarkdownDownload } from "@/core/utils/markdown-download";
|
||||||
@ -909,11 +911,26 @@ export function ArtifactFilePreview({
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
filepath?: string;
|
filepath?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const zoomScale = zoom / 100;
|
const zoomScale = zoom / 100;
|
||||||
const normalizedContent = useMemo(() => {
|
const normalizedContent = useMemo(() => {
|
||||||
return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
|
return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
|
||||||
}, [content, threadId, filepath]);
|
}, [content, threadId, filepath]);
|
||||||
|
|
||||||
|
const streamdownComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
a: CitationLink,
|
||||||
|
table: (props: ComponentPropsWithoutRef<"table">) => (
|
||||||
|
<MarkdownTable
|
||||||
|
copyLabel={t.clipboard.copyToClipboard}
|
||||||
|
downloadLabel={t.common.download}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[t.clipboard.copyToClipboard, t.common.download],
|
||||||
|
);
|
||||||
|
|
||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -923,7 +940,7 @@ export function ArtifactFilePreview({
|
|||||||
<Streamdown
|
<Streamdown
|
||||||
className="w-full"
|
className="w-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{ a: CitationLink }}
|
components={streamdownComponents}
|
||||||
>
|
>
|
||||||
{normalizedContent}
|
{normalizedContent}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
|
|||||||
@ -70,6 +70,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tag } from "@/components/ui/tag";
|
import { Tag } from "@/components/ui/tag";
|
||||||
import { useReferenceFiles } from "@/core/artifacts/references";
|
import { useReferenceFiles } from "@/core/artifacts/references";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
@ -286,6 +287,7 @@ export function InputBox({
|
|||||||
const [memoryPanelOpen, setMemoryPanelOpen] = useState(false);
|
const [memoryPanelOpen, setMemoryPanelOpen] = useState(false);
|
||||||
const [references, setReferences] = useState<PromptInputReference[]>([]);
|
const [references, setReferences] = useState<PromptInputReference[]>([]);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
|
const [mentionSearchText, setMentionSearchText] = useState("");
|
||||||
const [mentionOpen, setMentionOpen] = useState(false);
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||||
const [mentionRange, setMentionRange] = useState<{
|
const [mentionRange, setMentionRange] = useState<{
|
||||||
@ -294,7 +296,15 @@ export function InputBox({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
|
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
|
||||||
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
||||||
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
|
const { data: referenceFilesData, refetch: refetchReferenceFiles } =
|
||||||
|
useReferenceFiles(threadIdFromProps);
|
||||||
|
|
||||||
|
// 打开附件引用弹窗时刷新数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (mentionOpen) {
|
||||||
|
refetchReferenceFiles();
|
||||||
|
}
|
||||||
|
}, [mentionOpen, refetchReferenceFiles]);
|
||||||
|
|
||||||
// Welcome 态下禁用收缩,始终保持展开
|
// Welcome 态下禁用收缩,始终保持展开
|
||||||
const effectiveIsFocused =
|
const effectiveIsFocused =
|
||||||
@ -478,15 +488,24 @@ export function InputBox({
|
|||||||
|
|
||||||
const filteredMentionCandidates = useMemo(() => {
|
const filteredMentionCandidates = useMemo(() => {
|
||||||
const query = mentionQuery.trim().toLowerCase();
|
const query = mentionQuery.trim().toLowerCase();
|
||||||
if (!query) {
|
const search = mentionSearchText.trim().toLowerCase();
|
||||||
return mentionCandidates;
|
let result = mentionCandidates;
|
||||||
}
|
if (query) {
|
||||||
return mentionCandidates.filter((candidate) =>
|
result = result.filter((candidate) =>
|
||||||
`${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
|
`${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query),
|
.includes(query),
|
||||||
);
|
);
|
||||||
}, [mentionCandidates, mentionQuery]);
|
}
|
||||||
|
if (search) {
|
||||||
|
result = result.filter((candidate) =>
|
||||||
|
`${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [mentionCandidates, mentionQuery, mentionSearchText]);
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
@ -594,6 +613,7 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setMentionSearchText("");
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
setActiveMentionIndex(0);
|
setActiveMentionIndex(0);
|
||||||
setMentionRange(null);
|
setMentionRange(null);
|
||||||
@ -634,6 +654,7 @@ export function InputBox({
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setMentionSearchText("");
|
||||||
setActiveMentionIndex(0);
|
setActiveMentionIndex(0);
|
||||||
setMentionRange(null);
|
setMentionRange(null);
|
||||||
return;
|
return;
|
||||||
@ -683,6 +704,7 @@ export function InputBox({
|
|||||||
}
|
}
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setMentionSearchText("");
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
setMentionRange(null);
|
setMentionRange(null);
|
||||||
}
|
}
|
||||||
@ -861,6 +883,7 @@ export function InputBox({
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setMentionOpen(open);
|
setMentionOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
setMentionSearchText("");
|
||||||
setMentionRange(null);
|
setMentionRange(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -888,7 +911,30 @@ export function InputBox({
|
|||||||
<DropdownMenuLabel className="p-0 text-sm text-ws-fg-primary">
|
<DropdownMenuLabel className="p-0 text-sm text-ws-fg-primary">
|
||||||
{t.inputBox.addReference}
|
{t.inputBox.addReference}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
|
<Input
|
||||||
|
className="mt-3 h-8 text-sm"
|
||||||
|
placeholder="搜索文件..."
|
||||||
|
value={mentionSearchText}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => {
|
||||||
|
setMentionSearchText(e.target.value);
|
||||||
|
setActiveMentionIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
// 将焦点交还给 dropdown,让现有的键盘导航逻辑处理
|
||||||
|
const items =
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
'[data-testid="mention-candidate-item"]',
|
||||||
|
);
|
||||||
|
if (items.length > 0) {
|
||||||
|
(items[0] as HTMLElement).focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DropdownMenuSeparator className="mx-0 mt-3 mb-0" />
|
||||||
<DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0">
|
<DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0">
|
||||||
<ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}>
|
<ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}>
|
||||||
{filteredMentionCandidates.map((candidate, index) => {
|
{filteredMentionCandidates.map((candidate, index) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
|
import { DownloadIcon } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState, type MouseEvent } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
AnchorHTMLAttributes,
|
AnchorHTMLAttributes,
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
@ -14,7 +14,9 @@ import {
|
|||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { cn, copyToClipboard } from "@/lib/utils";
|
import { CopyButton } from "@/components/workspace/copy-button";
|
||||||
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
@ -56,21 +58,9 @@ function toMarkdownTable(data: TableData): string {
|
|||||||
return [headerLine, dividerLine, ...rowLines].join("\n");
|
return [headerLine, dividerLine, ...rowLines].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeCsvCell(value: string): string {
|
function downloadMarkdownFile(content: string, filename: string) {
|
||||||
if (!/[",\n\r]/.test(value)) return value;
|
|
||||||
return `"${value.replaceAll('"', '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCsvTable(data: TableData): string {
|
|
||||||
if (data.headers.length === 0) return "";
|
|
||||||
return [data.headers, ...data.rows]
|
|
||||||
.map((row) => row.map(escapeCsvCell).join(","))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCsvFile(content: string, filename: string) {
|
|
||||||
const blob = new Blob(["\uFEFF", content], {
|
const blob = new Blob(["\uFEFF", content], {
|
||||||
type: "text/csv;charset=utf-8",
|
type: "text/markdown;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
@ -80,58 +70,43 @@ function downloadCsvFile(content: string, filename: string) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownTable({
|
export function MarkdownTable({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
copyLabel,
|
copyLabel: _copyLabel,
|
||||||
downloadLabel,
|
downloadLabel,
|
||||||
...props
|
...props
|
||||||
}: ComponentPropsWithoutRef<"table"> & {
|
}: ComponentPropsWithoutRef<"table"> & {
|
||||||
copyLabel: string;
|
copyLabel: string;
|
||||||
downloadLabel: string;
|
downloadLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const tableRef = useRef<HTMLTableElement>(null);
|
||||||
|
const [, forceUpdate] = useState(0);
|
||||||
|
|
||||||
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
// 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新
|
||||||
const wrapper = event.currentTarget.closest(
|
useLayoutEffect(() => {
|
||||||
'[data-streamdown="table-wrapper"]',
|
forceUpdate((n) => n + 1);
|
||||||
);
|
|
||||||
const table = wrapper?.querySelector("table");
|
|
||||||
if (!(table instanceof HTMLTableElement)) return null;
|
|
||||||
return parseTableData(table);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(
|
// 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新
|
||||||
async (event: MouseEvent<HTMLButtonElement>) => {
|
// tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用
|
||||||
const data = getTableData(event);
|
const clipboardData = (() => {
|
||||||
if (!data) return;
|
const table = tableRef.current;
|
||||||
|
if (!table) return "";
|
||||||
|
const data = parseTableData(table);
|
||||||
|
if (!data) return "";
|
||||||
|
return toMarkdownTable(data);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const table = tableRef.current;
|
||||||
|
if (!table) return;
|
||||||
|
const data = parseTableData(table);
|
||||||
|
if (!data) return;
|
||||||
const markdown = toMarkdownTable(data);
|
const markdown = toMarkdownTable(data);
|
||||||
if (!markdown) return;
|
if (!markdown) return;
|
||||||
|
downloadMarkdownFile(markdown, "table.md");
|
||||||
try {
|
}, []);
|
||||||
await copyToClipboard(markdown);
|
|
||||||
setCopied(true);
|
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[getTableData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
|
||||||
(event: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
const data = getTableData(event);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const csv = toCsvTable(data);
|
|
||||||
if (!csv) return;
|
|
||||||
|
|
||||||
downloadCsvFile(csv, "table.csv");
|
|
||||||
},
|
|
||||||
[getTableData],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -139,25 +114,20 @@ function MarkdownTable({
|
|||||||
data-streamdown="table-wrapper"
|
data-streamdown="table-wrapper"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<CopyButton className="text-muted-foreground hover:bg-transparent hover:text-foreground cursor-pointer p-1 transition-all" clipboardData={clipboardData} />
|
||||||
|
<Tooltip content={downloadLabel}>
|
||||||
<button
|
<button
|
||||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
className="h-[32px] w-[32px] text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
||||||
onClick={handleCopy}
|
|
||||||
title={copyLabel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
title={downloadLabel}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<DownloadIcon size={14} />
|
<DownloadIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
|
ref={tableRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border w-full border-collapse border",
|
"border-border w-full border-collapse border",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@ -18,29 +18,27 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
|||||||
* In iframe context, sends message to parent window to handle clipboard operation.
|
* In iframe context, sends message to parent window to handle clipboard operation.
|
||||||
*/
|
*/
|
||||||
export async function copyToClipboard(text: string): Promise<void> {
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
const isInIframe = window.self !== window.top;
|
|
||||||
const message = {
|
const message = {
|
||||||
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
||||||
text,
|
text,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (isInIframe) {
|
console.log("[copyToClipboard] called, text length:", text.length);
|
||||||
|
|
||||||
|
// 始终发送 postMessage,由 sendToParent 内部判断是否为 iframe 环境
|
||||||
|
// 与 openSkillDialog 等其他 iframe 通信保持一致
|
||||||
try {
|
try {
|
||||||
// Request parent window to copy
|
|
||||||
sendToParent(message);
|
sendToParent(message);
|
||||||
console.log(
|
} catch {
|
||||||
"[copyToClipboard] iframe mode → postMessage to parent",
|
// no-op
|
||||||
message,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[copyToClipboard] iframe postMessage failed", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct clipboard access when not in iframe
|
// 同时也尝试直接写剪贴板(非 iframe 场景兜底)
|
||||||
console.log("[copyToClipboard] direct mode", message);
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
// no-op: 在 iframe 环境下由父窗口处理
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user