mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 22:21:33 +00:00
UI: polish agent skills, chat images, and sidebar status
This commit is contained in:
@@ -5,6 +5,7 @@ export const de: TranslationMap = {
|
||||
version: "Version",
|
||||
health: "Status",
|
||||
ok: "OK",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
connect: "Verbinden",
|
||||
refresh: "Aktualisieren",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const en: TranslationMap = {
|
||||
common: {
|
||||
health: "Health",
|
||||
ok: "OK",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
connect: "Connect",
|
||||
refresh: "Refresh",
|
||||
|
||||
@@ -5,6 +5,7 @@ export const es: TranslationMap = {
|
||||
version: "Versión",
|
||||
health: "Estado",
|
||||
ok: "Correcto",
|
||||
online: "En línea",
|
||||
offline: "Desconectado",
|
||||
connect: "Conectar",
|
||||
refresh: "Actualizar",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const pt_BR: TranslationMap = {
|
||||
common: {
|
||||
health: "Saúde",
|
||||
ok: "OK",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
connect: "Conectar",
|
||||
refresh: "Atualizar",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const zh_CN: TranslationMap = {
|
||||
common: {
|
||||
health: "健康状况",
|
||||
ok: "正常",
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
connect: "连接",
|
||||
refresh: "刷新",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const zh_TW: TranslationMap = {
|
||||
common: {
|
||||
health: "健康狀況",
|
||||
ok: "正常",
|
||||
online: "在線",
|
||||
offline: "離線",
|
||||
connect: "連接",
|
||||
refresh: "刷新",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-thread-inner> :first-child {
|
||||
.chat-thread-inner > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
}
|
||||
|
||||
/* Hide the "Message" label - keep textarea only */
|
||||
.chat-compose__field>span {
|
||||
.chat-compose__field > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.agent-chat__input>textarea {
|
||||
.agent-chat__input > textarea {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: 150px;
|
||||
@@ -378,7 +378,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agent-chat__input>textarea::placeholder {
|
||||
.agent-chat__input > textarea::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.slash-menu-group+.slash-menu-group {
|
||||
.slash-menu-group + .slash-menu-group {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
|
||||
@@ -82,6 +82,18 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-markdown .markdown-inline-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 420px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--secondary) 70%, transparent);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -56,6 +56,19 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chat-text :where(.markdown-inline-image) {
|
||||
display: block;
|
||||
max-width: min(100%, 420px);
|
||||
max-height: 320px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-top: 0.75em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--secondary) 70%, transparent);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-text :where(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
padding: 0.15em 0.4em;
|
||||
|
||||
@@ -2416,6 +2416,19 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chat-text :where(.markdown-inline-image) {
|
||||
display: block;
|
||||
max-width: min(100%, 420px);
|
||||
max-height: 320px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-top: 0.75em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--secondary) 70%, transparent);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-text :where(:not(pre) > code) {
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.shell {
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 288px;
|
||||
--shell-nav-width: 258px;
|
||||
--shell-nav-rail-width: 78px;
|
||||
--shell-topbar-height: 52px;
|
||||
--shell-focus-duration: 200ms;
|
||||
@@ -70,7 +70,7 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content>*+* {
|
||||
.shell--chat-focus .content > * + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 14px 14px 12px;
|
||||
padding: 14px 10px 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
@@ -503,7 +503,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
padding: 0 10px;
|
||||
min-height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -522,9 +522,9 @@
|
||||
}
|
||||
|
||||
.nav-section__label-text {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -555,9 +555,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 9px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
@@ -595,8 +595,8 @@
|
||||
}
|
||||
|
||||
.nav-item__text {
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -688,9 +688,11 @@
|
||||
|
||||
.sidebar--collapsed .nav-item.active,
|
||||
.sidebar--collapsed .nav-item--active {
|
||||
background: linear-gradient(180deg,
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, #0b2f34 84%, var(--bg-elevated) 16%) 0%,
|
||||
color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100%);
|
||||
color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100%
|
||||
);
|
||||
border-color: color-mix(in srgb, #1ed2c2 18%, var(--border) 82%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
|
||||
@@ -761,6 +763,24 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sidebar-version__status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sidebar-version__status.sidebar-connection-status--online {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 14%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-version__status.sidebar-connection-status--offline {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar-shell__footer {
|
||||
padding: 8px 0 2px;
|
||||
}
|
||||
@@ -778,6 +798,10 @@
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar-version__status {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .shell-nav {
|
||||
width: var(--shell-nav-rail-width);
|
||||
min-width: var(--shell-nav-rail-width);
|
||||
@@ -831,7 +855,7 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.content>*+* {
|
||||
.content > * + * {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -847,7 +871,7 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content--chat>*+* {
|
||||
.content--chat > * + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -906,7 +930,7 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content--chat .content-header>div:first-child {
|
||||
.content--chat .content-header > div:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
@@ -743,6 +743,23 @@ export function renderTopbarThemeModeToggle(state: AppViewState) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderSidebarConnectionStatus(state: AppViewState) {
|
||||
const label = state.connected ? t("common.online") : t("common.offline");
|
||||
const toneClass = state.connected
|
||||
? "sidebar-connection-status--online"
|
||||
: "sidebar-connection-status--offline";
|
||||
|
||||
return html`
|
||||
<span
|
||||
class="sidebar-version__status ${toneClass}"
|
||||
role="img"
|
||||
aria-live="polite"
|
||||
aria-label="Gateway status: ${label}"
|
||||
title="Gateway status: ${label}"
|
||||
></span>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderThemeToggle(state: AppViewState) {
|
||||
const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
|
||||
orb.classList.toggle("theme-orb--open", nextOpen);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
renderChatControls,
|
||||
renderChatSessionSelect,
|
||||
renderTab,
|
||||
renderSidebarConnectionStatus,
|
||||
renderTopbarThemeModeToggle,
|
||||
} from "./app-render.helpers.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
@@ -437,9 +438,7 @@ export function renderApp(state: AppViewState) {
|
||||
<span class="topbar-search__label">${t("common.search")}</span>
|
||||
<kbd class="topbar-search__kbd">⌘K</kbd>
|
||||
</button>
|
||||
<div class="topbar-status">
|
||||
${renderTopbarThemeModeToggle(state)}
|
||||
</div>
|
||||
<div class="topbar-status">${renderTopbarThemeModeToggle(state)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -543,9 +542,10 @@ export function renderApp(state: AppViewState) {
|
||||
? html`
|
||||
<span class="sidebar-version__label">${t("common.version")}</span>
|
||||
<span class="sidebar-version__text">v${version}</span>
|
||||
${renderSidebarConnectionStatus(state)}
|
||||
`
|
||||
: html`
|
||||
<span class="sidebar-version__dot"></span>
|
||||
${renderSidebarConnectionStatus(state)}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -915,6 +915,7 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
onRefresh: async () => {
|
||||
await loadAgents(state);
|
||||
await loadConfig(state);
|
||||
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
void loadAgentIdentities(state, agentIds);
|
||||
@@ -924,9 +925,24 @@ export function renderApp(state: AppViewState) {
|
||||
state.agentsList?.defaultId ??
|
||||
state.agentsList?.agents?.[0]?.id ??
|
||||
null;
|
||||
if (refreshedAgentId) {
|
||||
void loadAgentIdentity(state, refreshedAgentId);
|
||||
}
|
||||
if (state.agentsPanel === "files" && refreshedAgentId) {
|
||||
void loadAgentFiles(state, refreshedAgentId);
|
||||
}
|
||||
if (state.agentsPanel === "skills" && refreshedAgentId) {
|
||||
void loadAgentSkills(state, refreshedAgentId);
|
||||
}
|
||||
if (state.agentsPanel === "tools" && refreshedAgentId) {
|
||||
void loadToolsCatalog(state, refreshedAgentId);
|
||||
}
|
||||
if (state.agentsPanel === "channels") {
|
||||
void loadChannels(state, false);
|
||||
}
|
||||
if (state.agentsPanel === "cron") {
|
||||
void state.loadCron();
|
||||
}
|
||||
},
|
||||
onSelectAgent: (agentId) => {
|
||||
if (state.agentsSelectedId === agentId) {
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
it("preserves base64 data URI images (#15437)", () => {
|
||||
const html = toSanitizedMarkdownHtml("");
|
||||
expect(html).toContain("<img");
|
||||
expect(html).toContain('class="markdown-inline-image"');
|
||||
expect(html).toContain("data:image/png;base64,");
|
||||
});
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ htmlEscapeRenderer.image = (token: { href?: string | null; text?: string | null
|
||||
if (!INLINE_DATA_IMAGE_RE.test(href)) {
|
||||
return escapeHtml(label);
|
||||
}
|
||||
return `<img src="${escapeHtml(href)}" alt="${escapeHtml(label)}">`;
|
||||
return `<img class="markdown-inline-image" src="${escapeHtml(href)}" alt="${escapeHtml(label)}">`;
|
||||
};
|
||||
|
||||
function normalizeMarkdownImageLabel(text?: string | null): string {
|
||||
|
||||
24
ui/src/ui/sidebar-status.browser.test.ts
Normal file
24
ui/src/ui/sidebar-status.browser.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
|
||||
|
||||
registerAppMountHooks();
|
||||
|
||||
describe("sidebar connection status", () => {
|
||||
it("shows a single online status dot next to the version", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
app.hello = {
|
||||
ok: true,
|
||||
server: { version: "1.2.3" },
|
||||
} as never;
|
||||
app.requestUpdate();
|
||||
await app.updateComplete;
|
||||
|
||||
const version = app.querySelector<HTMLElement>(".sidebar-version");
|
||||
const statusDot = app.querySelector<HTMLElement>(".sidebar-version__status");
|
||||
expect(version).not.toBeNull();
|
||||
expect(statusDot).not.toBeNull();
|
||||
expect(statusDot?.getAttribute("aria-label")).toContain("Online");
|
||||
});
|
||||
});
|
||||
174
ui/src/ui/views/agents.test.ts
Normal file
174
ui/src/ui/views/agents.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderAgents, type AgentsProps } from "./agents.ts";
|
||||
|
||||
function createSkill() {
|
||||
return {
|
||||
name: "Repo Skill",
|
||||
description: "Skill description",
|
||||
source: "workspace",
|
||||
filePath: "/tmp/skill",
|
||||
baseDir: "/tmp",
|
||||
skillKey: "repo-skill",
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
eligible: true,
|
||||
requirements: {
|
||||
bins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
missing: {
|
||||
bins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
configChecks: [],
|
||||
install: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
|
||||
return {
|
||||
basePath: "",
|
||||
loading: false,
|
||||
error: null,
|
||||
agentsList: {
|
||||
defaultId: "alpha",
|
||||
mainKey: "main",
|
||||
scope: "workspace",
|
||||
agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never],
|
||||
},
|
||||
selectedAgentId: "beta",
|
||||
activePanel: "overview",
|
||||
config: {
|
||||
form: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
},
|
||||
channels: {
|
||||
snapshot: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastSuccess: null,
|
||||
},
|
||||
cron: {
|
||||
status: null,
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
agentFiles: {
|
||||
list: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
active: null,
|
||||
contents: {},
|
||||
drafts: {},
|
||||
saving: false,
|
||||
},
|
||||
agentIdentityLoading: false,
|
||||
agentIdentityError: null,
|
||||
agentIdentityById: {},
|
||||
agentSkills: {
|
||||
report: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
agentId: null,
|
||||
filter: "",
|
||||
},
|
||||
toolsCatalog: {
|
||||
loading: false,
|
||||
error: null,
|
||||
result: null,
|
||||
},
|
||||
onRefresh: () => undefined,
|
||||
onSelectAgent: () => undefined,
|
||||
onSelectPanel: () => undefined,
|
||||
onLoadFiles: () => undefined,
|
||||
onSelectFile: () => undefined,
|
||||
onFileDraftChange: () => undefined,
|
||||
onFileReset: () => undefined,
|
||||
onFileSave: () => undefined,
|
||||
onToolsProfileChange: () => undefined,
|
||||
onToolsOverridesChange: () => undefined,
|
||||
onConfigReload: () => undefined,
|
||||
onConfigSave: () => undefined,
|
||||
onModelChange: () => undefined,
|
||||
onModelFallbacksChange: () => undefined,
|
||||
onChannelsRefresh: () => undefined,
|
||||
onCronRefresh: () => undefined,
|
||||
onCronRunNow: () => undefined,
|
||||
onSkillsFilterChange: () => undefined,
|
||||
onSkillsRefresh: () => undefined,
|
||||
onAgentSkillToggle: () => undefined,
|
||||
onAgentSkillsClear: () => undefined,
|
||||
onAgentSkillsDisableAll: () => undefined,
|
||||
onSetDefault: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderAgents", () => {
|
||||
it("shows the skills count only for the selected agent's report", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderAgents(
|
||||
createProps({
|
||||
agentSkills: {
|
||||
report: {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [createSkill()],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
agentId: "alpha",
|
||||
filter: "",
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
|
||||
(button) => button.textContent?.includes("Skills"),
|
||||
);
|
||||
|
||||
expect(skillsTab?.textContent?.trim()).toBe("Skills");
|
||||
});
|
||||
|
||||
it("shows the selected agent's skills count when the report matches", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderAgents(
|
||||
createProps({
|
||||
agentSkills: {
|
||||
report: {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [createSkill()],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
agentId: "beta",
|
||||
filter: "",
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
|
||||
(button) => button.textContent?.includes("Skills"),
|
||||
);
|
||||
|
||||
expect(skillsTab?.textContent?.trim()).toContain("1");
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,10 @@ export function renderAgents(props: AgentsProps) {
|
||||
const selectedAgent = selectedId
|
||||
? (agents.find((agent) => agent.id === selectedId) ?? null)
|
||||
: null;
|
||||
const selectedSkillCount =
|
||||
selectedId && props.agentSkills.agentId === selectedId
|
||||
? (props.agentSkills.report?.skills?.length ?? null)
|
||||
: null;
|
||||
|
||||
const channelEntryCount = props.channels.snapshot
|
||||
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
|
||||
@@ -122,7 +126,7 @@ export function renderAgents(props: AgentsProps) {
|
||||
: null;
|
||||
const tabCounts: Record<string, number | null> = {
|
||||
files: props.agentFiles.list?.files?.length ?? null,
|
||||
skills: props.agentSkills.report?.skills?.length ?? null,
|
||||
skills: selectedSkillCount,
|
||||
channels: channelEntryCount,
|
||||
cron: cronJobCount || null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user