feat: update openclaw and polish desktop flows
This commit is contained in:
@@ -273,7 +273,10 @@ export function ChannelConfigModal({
|
||||
const err = typeof args[0] === 'string'
|
||||
? args[0]
|
||||
: String((args[0] as { message?: string } | undefined)?.message || args[0]);
|
||||
toast.error(translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err }));
|
||||
const errorText = channelType === 'whatsapp' && /websocket|network|proxy/i.test(err)
|
||||
? translateRef.current('toast.whatsappNetworkHint', { error: err })
|
||||
: translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err });
|
||||
toast.error(errorText);
|
||||
setQrCode(null);
|
||||
setConnecting(false);
|
||||
};
|
||||
@@ -370,10 +373,13 @@ export function ChannelConfigModal({
|
||||
}
|
||||
|
||||
if (meta.connectionType === 'qr') {
|
||||
await hostApiFetch(`/api/channels/${encodeURIComponent(selectedType)}/start`, {
|
||||
const startResult = await hostApiFetch<{ success?: boolean; error?: string }>(`/api/channels/${encodeURIComponent(selectedType)}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
|
||||
});
|
||||
if (!startResult?.success) {
|
||||
throw new Error(startResult?.error || 'Failed to start QR login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { CronJob } from '@/types/cron';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
@@ -123,8 +124,22 @@ const sidebarItemActive = [
|
||||
'dark:border-white/10 dark:bg-white/10 dark:text-blue-100 dark:ring-white/10',
|
||||
].join(' ');
|
||||
|
||||
function NavItem({ to, icon, label, badge, collapsed, onClick, testId, inset }: NavItemProps) {
|
||||
function SidebarTooltip({ label, children, enabled = true }: { label: string; children: React.ReactElement; enabled?: boolean }) {
|
||||
if (!enabled) return children;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block w-full">{children}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" sideOffset={10} className="whitespace-nowrap text-xs font-medium">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ to, icon, label, badge, collapsed, onClick, testId, inset }: NavItemProps) {
|
||||
const link = (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
@@ -158,6 +173,12 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId, inset }:
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarTooltip label={label} enabled={collapsed}>
|
||||
{link}
|
||||
</SidebarTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
|
||||
@@ -634,6 +655,7 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<>
|
||||
<aside
|
||||
data-testid="sidebar"
|
||||
@@ -706,42 +728,48 @@ export function Sidebar() {
|
||||
{sidebarCollapsed && (
|
||||
<div className="px-2 pb-2 pt-2">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
data-testid="sidebar-new-chat"
|
||||
title={newChatLabel}
|
||||
onClick={() => {
|
||||
const { messages, currentSessionKey } = useChatStore.getState();
|
||||
if (messages.length > 0 || !currentSessionKey.startsWith('agent:main:')) {
|
||||
newSession();
|
||||
}
|
||||
navigate('/chat');
|
||||
}}
|
||||
className="flex w-full justify-center rounded-lg border border-[#D5E8F3]/70 bg-white/50 px-0 py-2 text-[#075985] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-md transition-colors hover:border-[#BFDCEC] hover:bg-white/70 dark:border-white/10 dark:bg-white/10 dark:text-blue-100 dark:hover:bg-white/15"
|
||||
>
|
||||
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</button>
|
||||
<Button
|
||||
data-testid="sidebar-collapse-toggle"
|
||||
title={expandLabel}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-full rounded-lg text-muted-foreground hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10"
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<PanelLeft className="h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
<button
|
||||
data-testid="sidebar-chat-history"
|
||||
title={historyLabel}
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
aria-expanded={historyOpen}
|
||||
className={cn(
|
||||
'flex w-full justify-center rounded-lg border border-transparent px-0 py-2 transition-colors hover:bg-white/70 dark:hover:bg-white/5',
|
||||
isHistoryActive ? sidebarItemActive : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</button>
|
||||
<SidebarTooltip label={newChatLabel}>
|
||||
<button
|
||||
data-testid="sidebar-new-chat"
|
||||
title={newChatLabel}
|
||||
onClick={() => {
|
||||
const { messages, currentSessionKey } = useChatStore.getState();
|
||||
if (messages.length > 0 || !currentSessionKey.startsWith('agent:main:')) {
|
||||
newSession();
|
||||
}
|
||||
navigate('/chat');
|
||||
}}
|
||||
className="flex w-full justify-center rounded-lg border border-[#D5E8F3]/70 bg-white/50 px-0 py-2 text-[#075985] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-md transition-colors hover:border-[#BFDCEC] hover:bg-white/70 dark:border-white/10 dark:bg-white/10 dark:text-blue-100 dark:hover:bg-white/15"
|
||||
>
|
||||
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
<SidebarTooltip label={expandLabel}>
|
||||
<Button
|
||||
data-testid="sidebar-collapse-toggle"
|
||||
title={expandLabel}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-full rounded-lg text-muted-foreground hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10"
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<PanelLeft className="h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
</SidebarTooltip>
|
||||
<SidebarTooltip label={historyLabel}>
|
||||
<button
|
||||
data-testid="sidebar-chat-history"
|
||||
title={historyLabel}
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
aria-expanded={historyOpen}
|
||||
className={cn(
|
||||
'flex w-full justify-center rounded-lg border border-transparent px-0 py-2 transition-colors hover:bg-white/70 dark:hover:bg-white/5',
|
||||
isHistoryActive ? sidebarItemActive : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -853,27 +881,29 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
{isE2EMode && devModeUnlocked && (
|
||||
<Button
|
||||
data-testid="sidebar-open-dev-console"
|
||||
title={t('common:sidebar.openClawPage')}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
|
||||
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
|
||||
)}
|
||||
onClick={openDevConsole}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<SidebarTooltip label={t('common:sidebar.openClawPage')} enabled={sidebarCollapsed}>
|
||||
<Button
|
||||
data-testid="sidebar-open-dev-console"
|
||||
title={t('common:sidebar.openClawPage')}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
|
||||
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
|
||||
)}
|
||||
onClick={openDevConsole}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</SidebarTooltip>
|
||||
)}
|
||||
<div
|
||||
data-testid="sidebar-account-summary"
|
||||
@@ -891,20 +921,22 @@ export function Sidebar() {
|
||||
>
|
||||
<img src={logoSvg} alt={t('common:appName')} className="h-7 w-7 rounded-lg" />
|
||||
</div>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
data-testid="sidebar-nav-settings"
|
||||
title={t('sidebar.settings')}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10',
|
||||
isActive && 'bg-white/70 text-[#075985] dark:bg-white/10 dark:text-blue-100',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
<span className="sr-only">{t('sidebar.settings')}</span>
|
||||
</NavLink>
|
||||
<SidebarTooltip label={t('sidebar.settings')}>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
data-testid="sidebar-nav-settings"
|
||||
title={t('sidebar.settings')}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10',
|
||||
isActive && 'bg-white/70 text-[#075985] dark:bg-white/10 dark:text-blue-100',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
<span className="sr-only">{t('sidebar.settings')}</span>
|
||||
</NavLink>
|
||||
</SidebarTooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
@@ -1112,5 +1144,6 @@ export function Sidebar() {
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"whatsappFailed": "WhatsApp connection failed: {{error}}",
|
||||
"qrConnected": "{{name}} connected successfully",
|
||||
"qrFailed": "{{name}} connection failed: {{error}}",
|
||||
"whatsappNetworkHint": "Could not connect to WhatsApp WebSocket. Check network connectivity or enable a proxy in Advanced Settings. {{error}}",
|
||||
"channelSaved": "Channel {{name}} saved",
|
||||
"channelConnecting": "Connecting to {{name}}...",
|
||||
"savedButRefreshFailed": "Configuration was saved, but refreshing page data failed. Please refresh manually.",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}",
|
||||
"qrConnected": "{{name}} が正常に接続されました",
|
||||
"qrFailed": "{{name}} の接続に失敗しました: {{error}}",
|
||||
"whatsappNetworkHint": "WhatsApp WebSocket に接続できません。ネットワークを確認するか、詳細設定でプロキシを有効にしてください。{{error}}",
|
||||
"channelSaved": "チャンネル {{name}} が保存されました",
|
||||
"channelConnecting": "{{name}} に接続中...",
|
||||
"savedButRefreshFailed": "設定は保存されましたが、画面データの更新に失敗しました。手動で再読み込みしてください。",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"whatsappFailed": "Не удалось подключить WhatsApp: {{error}}",
|
||||
"qrConnected": "{{name}} успешно подключён",
|
||||
"qrFailed": "Не удалось подключить {{name}}: {{error}}",
|
||||
"whatsappNetworkHint": "Не удалось подключиться к WhatsApp WebSocket. Проверьте сеть или включите прокси в расширенных настройках. {{error}}",
|
||||
"channelSaved": "Канал {{name}} сохранён",
|
||||
"channelConnecting": "Подключение к {{name}}...",
|
||||
"savedButRefreshFailed": "Конфигурация сохранена, но обновление данных страницы не удалось. Пожалуйста, обновите вручную.",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"whatsappFailed": "WhatsApp 连接失败: {{error}}",
|
||||
"qrConnected": "{{name}} 连接成功",
|
||||
"qrFailed": "{{name}} 连接失败: {{error}}",
|
||||
"whatsappNetworkHint": "无法连接 WhatsApp WebSocket。请检查网络,或在高级设置中开启代理。{{error}}",
|
||||
"channelSaved": "频道 {{name}} 已保存",
|
||||
"channelConnecting": "正在连接 {{name}}...",
|
||||
"savedButRefreshFailed": "配置已保存,但刷新页面数据失败,请手动刷新查看最新状态",
|
||||
|
||||
@@ -89,6 +89,14 @@ function parseUnifiedProxyResponse<T>(
|
||||
}
|
||||
|
||||
const data: HostApiProxyData = response.data ?? {};
|
||||
if (data.ok === false) {
|
||||
const message = data.text
|
||||
|| (typeof data.json === 'object' && data.json != null && 'error' in (data.json as Record<string, unknown>)
|
||||
? String((data.json as Record<string, unknown>).error)
|
||||
: `HTTP ${data.status ?? 'unknown'}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
method,
|
||||
|
||||
@@ -32,9 +32,7 @@ export function AppCenter() {
|
||||
const init = useAppCenterStore((state) => state.init);
|
||||
const items = useAppCenterStore((state) => state.items);
|
||||
const selectedTagKey = useAppCenterStore((state) => state.selectedTagKey);
|
||||
const selectedItemId = useAppCenterStore((state) => state.selectedItemId);
|
||||
const selectTag = useAppCenterStore((state) => state.selectTag);
|
||||
const selectItem = useAppCenterStore((state) => state.selectItem);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
@@ -127,13 +125,12 @@ export function AppCenter() {
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,260px))] justify-center gap-4 pb-2 sm:justify-start">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = getAppIcon(item.icon);
|
||||
const isSelected = selectedItemId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectItem(item.id);
|
||||
onClick={(event) => {
|
||||
event.currentTarget.blur();
|
||||
openItem(item);
|
||||
}}
|
||||
data-testid={`app-center-item-${item.id}`}
|
||||
@@ -142,9 +139,7 @@ export function AppCenter() {
|
||||
'hover:border-[#7DBADB] hover:bg-white hover:shadow-[0_18px_42px_rgba(15,23,42,0.09)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1E3A8A]/30',
|
||||
'dark:border-white/10 dark:bg-slate-950/75 dark:hover:shadow-[0_10px_24px_rgba(0,0,0,0.24)]',
|
||||
isSelected
|
||||
? 'border-[#0369A1] bg-[#F6FBFE] shadow-[0_14px_30px_rgba(3,105,161,0.10)]'
|
||||
: 'border-slate-200/80',
|
||||
'border-slate-200/80',
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-[linear-gradient(180deg,rgba(229,244,250,0.85),rgba(255,255,255,0))] dark:bg-[linear-gradient(180deg,rgba(30,58,138,0.18),rgba(15,23,42,0))]" />
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||
import {
|
||||
getGatewayWsDiagnosticEnabled,
|
||||
invokeIpc,
|
||||
@@ -1109,6 +1110,12 @@ export function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devModeUnlocked && (
|
||||
<div className="yinian-panel p-4" data-testid="settings-model-config-section">
|
||||
<ProvidersSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devModeUnlocked && (
|
||||
<div className="yinian-panel p-4" data-testid="settings-model-diagnostics">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Plus,
|
||||
Play,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -27,12 +26,10 @@ import {
|
||||
YinianEmptyState,
|
||||
YinianHeaderActions,
|
||||
YinianInfoRow,
|
||||
YinianMetricCard,
|
||||
YinianNotice,
|
||||
YinianPageHeader,
|
||||
YinianPageShell,
|
||||
YinianPanel,
|
||||
yinianAccent,
|
||||
yinianPrimaryButton,
|
||||
} from '@/components/yinian/ui';
|
||||
import type { YinianLocalSkill, YinianSkillEntitlement } from '../../../shared/yinian';
|
||||
@@ -147,12 +144,6 @@ export function YinianSkills() {
|
||||
const visibleOpenClawSkills = openClawSkills
|
||||
.filter((skill) => !isOpenClawBuiltInSkill(skill))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, i18n.language === 'zh' ? 'zh-CN' : 'en-US'));
|
||||
const states = config.entitlements.map((skill) => getManagerState(skill, localById.get(skill.skillId)));
|
||||
const runnableCount = states.filter(canRun).length;
|
||||
const notSyncedCount = states.filter((state) => state === 'not-synced').length;
|
||||
const updateCount = states.filter((state) => state === 'update-available').length;
|
||||
const failedCount = states.filter((state) => state === 'failed').length;
|
||||
const disabledCount = states.filter((state) => state === 'disabled').length;
|
||||
const selectedServerSkill = config.entitlements.find((skill) => skill.skillId === selectedServerSkillId);
|
||||
const selectedServerLocal = selectedServerSkill ? localById.get(selectedServerSkill.skillId) : undefined;
|
||||
const selectedServerState = selectedServerSkill ? getManagerState(selectedServerSkill, selectedServerLocal) : null;
|
||||
@@ -255,18 +246,6 @@ export function YinianSkills() {
|
||||
</YinianNotice>
|
||||
)}
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
{[
|
||||
{ label: t('business.metrics.enabled'), value: String(config.entitlements.length), icon: ShieldCheck, tone: yinianAccent },
|
||||
{ label: t('business.metrics.runnable'), value: String(runnableCount), icon: CheckCircle2, tone: 'text-emerald-600' },
|
||||
{ label: t('business.metrics.notSynced'), value: String(notSyncedCount), icon: CloudDownload, tone: 'text-slate-600 dark:text-slate-300' },
|
||||
{ label: t('business.metrics.updates'), value: String(updateCount), icon: RefreshCw, tone: 'text-amber-600' },
|
||||
{ label: t('business.metrics.issues'), value: String(failedCount + disabledCount), icon: AlertCircle, tone: failedCount ? 'text-red-600' : 'text-slate-500' },
|
||||
].map((item) => (
|
||||
<YinianMetricCard key={item.label} {...item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-2 rounded-lg border border-slate-200/70 bg-white/60 p-1 dark:border-white/10 dark:bg-white/5">
|
||||
{[
|
||||
{ key: 'quickTasks' as const, label: t('business.quickTasks.title'), count: quickTasks.length },
|
||||
|
||||
@@ -49,7 +49,7 @@ export const useAppCenterStore = create<AppCenterState>((set, get) => ({
|
||||
|
||||
init: () => {
|
||||
if (get().initialized) return;
|
||||
set({ items: BUILT_IN_APPS, selectedItemId: BUILT_IN_APPS[0]?.id ?? null, initialized: true });
|
||||
set({ items: BUILT_IN_APPS, selectedItemId: null, initialized: true });
|
||||
},
|
||||
|
||||
selectItem: (itemId) => set({ selectedItemId: itemId }),
|
||||
|
||||
Reference in New Issue
Block a user