mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
refactor(webui): narrow floating control migration
This commit is contained in:
@@ -92,8 +92,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
@@ -3999,26 +3997,14 @@ function ProviderAdvancedOptions({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[220px]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={form.apiType}
|
||||
onValueChange={(value) => {
|
||||
const option = OPENAI_API_TYPE_OPTIONS.find(
|
||||
(candidate) => candidate.value === value,
|
||||
);
|
||||
if (option) onChange({ apiType: option.value });
|
||||
}}
|
||||
>
|
||||
{OPENAI_API_TYPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
indicator="check"
|
||||
indicatorPosition="end"
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{OPENAI_API_TYPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => onChange({ apiType: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</label>
|
||||
@@ -4047,22 +4033,15 @@ function ProviderAdvancedOptions({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[220px]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={form.thinkingStyle}
|
||||
onValueChange={(value) => onChange({ thinkingStyle: value })}
|
||||
>
|
||||
{thinkingStyleOptions.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value || "default"}
|
||||
value={option.value}
|
||||
indicator="check"
|
||||
indicatorPosition="end"
|
||||
className="font-mono text-[12px]"
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{thinkingStyleOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value || "default"}
|
||||
onSelect={() => onChange({ thinkingStyle: option.value })}
|
||||
className="font-mono text-[12px]"
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</label>
|
||||
@@ -5524,25 +5503,12 @@ function AutomationsSettings({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sort}
|
||||
onValueChange={(value) => {
|
||||
const option = (Object.keys(sortLabel) as AutomationSort[])
|
||||
.find((candidate) => candidate === value);
|
||||
if (option) onSortChange(option);
|
||||
}}
|
||||
>
|
||||
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={value}
|
||||
value={value}
|
||||
indicator="check"
|
||||
indicatorPosition="end"
|
||||
>
|
||||
{sortLabel[value]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
||||
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
||||
<span>{sortLabel[value]}</span>
|
||||
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -6155,51 +6121,27 @@ function AutomationEditDialog({
|
||||
className="h-10 rounded-[12px]"
|
||||
/>
|
||||
</label>
|
||||
<div className="block space-y-1.5">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.automations.fields.unit", "Unit")}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={tx("settings.automations.fields.unit", "Unit")}
|
||||
className="h-10 w-full justify-between rounded-[12px] px-3 text-[13px] font-normal"
|
||||
>
|
||||
<span>{unitLabels[draft.everyUnit]}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={draft.everyUnit}
|
||||
onValueChange={(value) => {
|
||||
const unit = AUTOMATION_EVERY_UNITS.find(
|
||||
(candidate) => candidate.value === value,
|
||||
);
|
||||
if (unit) {
|
||||
setDraft((prev) => ({ ...prev, everyUnit: unit.value }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{AUTOMATION_EVERY_UNITS.map((unit) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={unit.value}
|
||||
value={unit.value}
|
||||
indicator="check"
|
||||
indicatorPosition="end"
|
||||
>
|
||||
{unitLabels[unit.value]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<select
|
||||
value={draft.everyUnit}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
everyUnit: event.target.value as AutomationEveryUnit,
|
||||
}))
|
||||
}
|
||||
className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{AUTOMATION_EVERY_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unitLabels[unit.value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -9043,16 +8985,15 @@ function ProviderPicker({
|
||||
align="end"
|
||||
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={onChange}>
|
||||
{providers.map((provider) => (
|
||||
<DropdownMenuRadioItem
|
||||
{providers.map((provider) => {
|
||||
const selected = provider.name === value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider.name}
|
||||
value={provider.name}
|
||||
indicator="check"
|
||||
indicatorPosition="end"
|
||||
onSelect={() => onChange(provider.name)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-[12px] py-2 text-[13px]",
|
||||
"focus:bg-muted/85 focus:text-foreground data-[state=checked]:bg-muted/80",
|
||||
"flex cursor-default items-center justify-between gap-2 text-[13px]",
|
||||
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
@@ -9064,9 +9005,10 @@ function ProviderPicker({
|
||||
) : null}
|
||||
<span className="truncate">{provider.label}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -294,51 +293,46 @@ export function WorkspaceAccessMenu({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuRadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (value === "restricted" || value === "full") setMode(value);
|
||||
}}
|
||||
>
|
||||
<AccessMenuItem
|
||||
value="restricted"
|
||||
icon={<Hand className="h-4 w-4" />}
|
||||
label={t("thread.composer.workspace.default")}
|
||||
/>
|
||||
<AccessMenuItem
|
||||
value="full"
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label={t("thread.composer.workspace.full")}
|
||||
disabled={!canUseFullAccess}
|
||||
warning
|
||||
/>
|
||||
</DropdownMenuRadioGroup>
|
||||
<AccessMenuItem
|
||||
icon={<Hand className="h-4 w-4" />}
|
||||
label={t("thread.composer.workspace.default")}
|
||||
selected={mode === "restricted"}
|
||||
onSelect={() => setMode("restricted")}
|
||||
/>
|
||||
<AccessMenuItem
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label={t("thread.composer.workspace.full")}
|
||||
selected={mode === "full"}
|
||||
disabled={!canUseFullAccess}
|
||||
warning
|
||||
onSelect={() => setMode("full")}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessMenuItem({
|
||||
value,
|
||||
icon,
|
||||
label,
|
||||
selected,
|
||||
disabled,
|
||||
warning,
|
||||
onSelect,
|
||||
}: {
|
||||
value: WorkspaceAccessMode;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
warning?: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
value={value}
|
||||
indicator="check"
|
||||
indicatorPosition="end"
|
||||
<DropdownMenuItem
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-3 rounded-xl pl-3 text-[13.5px] font-semibold",
|
||||
"flex h-10 items-center gap-3 px-3 text-[13.5px] font-semibold",
|
||||
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
|
||||
)}
|
||||
>
|
||||
@@ -346,6 +340,7 @@ function AccessMenuItem({
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
"min-w-[10rem]",
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +74,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"min-w-[10rem]",
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -130,32 +130,20 @@ DropdownMenuCheckboxItem.displayName =
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
indicator?: "dot" | "check";
|
||||
indicatorPosition?: "start" | "end";
|
||||
}
|
||||
>(({ className, children, indicator = "dot", indicatorPosition = "start", ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuItemClassName,
|
||||
indicatorPosition === "start" ? "pl-8 pr-2.5" : "pl-2.5 pr-8",
|
||||
"pl-8 pr-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute flex h-3.5 w-3.5 items-center justify-center",
|
||||
indicatorPosition === "start" ? "left-2.5" : "right-2.5",
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
{indicator === "check" ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
)}
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,7 @@ export const floatingSurfaceVisualClassName =
|
||||
"rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
|
||||
|
||||
export const floatingSurfaceClassName =
|
||||
`${floatingSurfaceVisualClassName} z-50 max-h-[min(var(--radix-popper-available-height),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
|
||||
`${floatingSurfaceVisualClassName} z-50 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
|
||||
|
||||
export const floatingSurfaceMotionClassName =
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
|
||||
|
||||
@@ -26,6 +26,7 @@ const PopoverContent = React.forwardRef<
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"max-h-[min(var(--radix-popover-content-available-height),28rem)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2020,7 +2020,7 @@ describe("App layout", () => {
|
||||
);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
||||
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /Auto/ }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
||||
const openModelPicker = async () => {
|
||||
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
|
||||
await user.click(modelButtons[modelButtons.length - 1]);
|
||||
@@ -2091,9 +2091,9 @@ describe("App layout", () => {
|
||||
target: { value: "unsaved-brave-key" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ }));
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Tavily" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Tavily" }));
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ }));
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Brave Search" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
|
||||
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("combobox navigation", () => {
|
||||
const input = screen.getByRole("combobox", { name: "Options" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open options" }));
|
||||
|
||||
const selectedOption = screen.getByRole("option", { name: "Beta" });
|
||||
@@ -107,13 +108,4 @@ describe("combobox navigation", () => {
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the listbox on Escape", () => {
|
||||
render(<ComboboxHarness />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("combobox", { name: "Options" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3211,7 +3211,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
target: { value: "http://127.0.0.1:7890" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "enable_thinking" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -3277,7 +3277,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Gemini" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
|
||||
await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
|
||||
@@ -3293,7 +3293,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Custom" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
|
||||
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
|
||||
|
||||
await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
|
||||
@@ -3577,9 +3577,9 @@ describe("SettingsView Apps catalog", () => {
|
||||
if (!providerPicker) throw new Error("provider picker was not found");
|
||||
fireEvent.pointerDown(providerPicker);
|
||||
|
||||
expect(await screen.findByRole("menuitemradio", { name: /DeepSeek/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitemradio", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitemradio", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch model lists for unsigned OAuth providers", async () => {
|
||||
@@ -4028,7 +4028,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView({ initialSection: "browser" });
|
||||
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Keenable" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" }));
|
||||
const saveButton = screen
|
||||
.getAllByRole("button", { name: "Save" })
|
||||
.find((button) => !(button as HTMLButtonElement).disabled);
|
||||
|
||||
@@ -968,7 +968,7 @@ describe("ThreadComposer", () => {
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Full Access/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
|
||||
|
||||
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
Reference in New Issue
Block a user