feat(desktop): add macOS dock badge for chats with unseen activity
Show a count of chats (root sessions) with unseen activity on the macOS dock icon. The count is computed in the existing tray snapshot (full cross-project list, not the capped tray view; a subtask's unseen rolls up to its root only when subtask notifications are enabled) and pushed to the main process over the existing desktop_tray_update IPC, which calls app.setBadgeCount (0 clears it). The badge clears as sessions are marked seen on window focus. Add a Dock badge toggle in Appearance settings (default on, persisted, darwin desktop only), localized across all dictionaries, with a matching settings-search entry whose availability mirrors the render guard exactly.
This commit is contained in:
@@ -3265,6 +3265,17 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
log.warn('[electron] tray update failed', error);
|
||||
}
|
||||
}
|
||||
// Dock badge: count of chats with unseen activity (0 = cleared, also when
|
||||
// the user disabled the badge). setBadgeCount drives the macOS dock badge.
|
||||
try {
|
||||
const rawCount = args && typeof args.dockBadgeCount === 'number' ? args.dockBadgeCount : 0;
|
||||
const badgeCount = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0;
|
||||
if (typeof app.setBadgeCount === 'function') {
|
||||
app.setBadgeCount(badgeCount);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('[electron] dock badge update failed', error);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'desktop_clear_cache':
|
||||
|
||||
@@ -336,6 +336,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const macVibrancyEnabled = typeof window !== 'undefined' && window.__OPENCHAMBER_ELECTRON__?.macVibrancy === true;
|
||||
const [vibrancyChecked, setVibrancyChecked] = React.useState(macVibrancyEnabled);
|
||||
const [vibrancyRestarting, setVibrancyRestarting] = React.useState(false);
|
||||
|
||||
// macOS-desktop-only dock badge that counts chats with unseen activity.
|
||||
// The tray sync (mac-only) pumps the count to the main process, so the
|
||||
// toggle is offered only where it actually has an effect. No relaunch needed.
|
||||
const dockBadgeSupported = React.useMemo(
|
||||
() => isDesktopShell() && typeof window !== 'undefined'
|
||||
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'darwin',
|
||||
[],
|
||||
);
|
||||
const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled);
|
||||
const setDockBadgeEnabled = useUIStore(state => state.setDockBadgeEnabled);
|
||||
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
|
||||
const reportUsage = useUIStore(state => state.reportUsage);
|
||||
const setReportUsage = useUIStore(state => state.setReportUsage);
|
||||
@@ -854,6 +865,38 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dockBadgeSupported && (
|
||||
<div data-settings-item="appearance.dock-badge" className="flex flex-col gap-1.5 border-t border-border/40 pt-3">
|
||||
<div
|
||||
className="group flex cursor-pointer items-start gap-2 py-0.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={dockBadgeEnabled}
|
||||
onClick={() => setDockBadgeEnabled(!dockBadgeEnabled)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setDockBadgeEnabled(!dockBadgeEnabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={dockBadgeEnabled}
|
||||
onChange={setDockBadgeEnabled}
|
||||
ariaLabel={t('settings.openchamber.visual.field.dockBadge')}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{t('settings.openchamber.visual.field.dockBadge')}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{t('settings.openchamber.visual.field.dockBadgeHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -330,6 +330,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const isDesktopLocalOrigin = React.useMemo(() => {
|
||||
return isDesktopShell() && isDesktopLocalOriginActive();
|
||||
}, []);
|
||||
const isMac = React.useMemo(() => {
|
||||
return isDesktopShell() && typeof window !== 'undefined'
|
||||
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'darwin';
|
||||
}, []);
|
||||
|
||||
// keep platform check available for future window chrome tweaks
|
||||
|
||||
@@ -532,12 +536,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const settingsSearchResults = React.useMemo(() => {
|
||||
return buildSettingsSearchResults({
|
||||
query: settingsSearchQuery,
|
||||
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin },
|
||||
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac },
|
||||
visiblePageSlugs,
|
||||
t,
|
||||
getPageTitle,
|
||||
});
|
||||
}, [getPageTitle, isDesktopLocalOrigin, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
|
||||
}, [getPageTitle, isDesktopLocalOrigin, isMac, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
|
||||
|
||||
const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => {
|
||||
if (result.id.startsWith('agents.')) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveProjectForSessionDirectory, normalizeProjectPath } from '@/lib/projectResolution';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
@@ -80,6 +81,9 @@ type TraySnapshot = {
|
||||
// dropdown (same "configured to show" rule as the header/mobile). Empty
|
||||
// groups → the tray omits the Usage submenu entirely.
|
||||
usage: TrayUsage;
|
||||
// Number of chats (root sessions) with unseen activity, for the macOS dock
|
||||
// badge. 0 when the user disabled the badge — the main process clears it.
|
||||
dockBadgeCount: number;
|
||||
};
|
||||
|
||||
// focus-session / new-session are routed natively by the main process through
|
||||
@@ -389,7 +393,26 @@ const buildSnapshot = (instanceName: string): TraySnapshot => {
|
||||
|
||||
const approvals = live.approvals.map((a) => ({ ...a, sessionTitle: titleById.get(a.sessionId) || '' }));
|
||||
|
||||
return { sessions, approvals, instanceName, usage: buildUsage() };
|
||||
// Dock badge: count chats (root sessions) with unseen activity over the FULL
|
||||
// cross-project list — not the MAX_SESSIONS-capped `sessions` above — so the
|
||||
// number is accurate even with many projects. A subtask's unseen rolls up to
|
||||
// its root only when the user opted into subtask notifications, matching the
|
||||
// sidebar's needs-attention rule.
|
||||
const ui = useUIStore.getState();
|
||||
let dockBadgeCount = 0;
|
||||
if (ui.dockBadgeEnabled) {
|
||||
for (const session of allSessions) {
|
||||
if (!session?.id || session.parentID) continue; // roots only
|
||||
let familyUnseen = notif.unseenCount[session.id] ?? 0;
|
||||
if (familyUnseen === 0 && ui.notifyOnSubtasks) {
|
||||
familyUnseen = collectDescendants(session.id)
|
||||
.reduce((sum, id) => sum + (notif.unseenCount[id] ?? 0), 0);
|
||||
}
|
||||
if (familyUnseen > 0) dockBadgeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { sessions, approvals, instanceName, usage: buildUsage(), dockBadgeCount };
|
||||
};
|
||||
|
||||
export const useTraySync = (): void => {
|
||||
@@ -491,6 +514,9 @@ export const useTraySync = (): void => {
|
||||
const unsubscribeProjects = useProjectsStore.subscribe(() => scheduleFlush());
|
||||
const unsubscribeWorktrees = useSessionUIStore.subscribe(() => scheduleFlush());
|
||||
const unsubscribeGit = useGitStore.subscribe(() => scheduleFlush());
|
||||
// The dock-badge toggle and subtask-notification preference live here; a
|
||||
// change must re-push the snapshot so the badge appears/clears immediately.
|
||||
const unsubscribeUI = useUIStore.subscribe(() => scheduleFlush());
|
||||
// Cross-project status map: fed live by the sync dispatcher from the global
|
||||
// event stream, and seeded/reconciled by the poll below.
|
||||
const unsubscribeGlobalStatus = useGlobalSessionStatusStore.subscribe(() => scheduleFlush());
|
||||
@@ -541,6 +567,7 @@ export const useTraySync = (): void => {
|
||||
unsubscribeProjects();
|
||||
unsubscribeWorktrees();
|
||||
unsubscribeGit();
|
||||
unsubscribeUI();
|
||||
unsubscribeGlobalStatus();
|
||||
unsubscribeQuota();
|
||||
unsubscribeRegistry?.();
|
||||
|
||||
@@ -1606,6 +1606,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.actions.reloadingThemes': 'Reloading themes...',
|
||||
'settings.openchamber.visual.field.macVibrancy': 'Window transparency',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': 'Use the native macOS blur (vibrancy) behind the sidebar. Turn off for fully solid, opaque surfaces.',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Dock badge',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': 'Show a count of chats with unseen activity on the macOS dock icon.',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': 'Save & restart',
|
||||
'settings.openchamber.visual.actions.restarting': 'Restarting…',
|
||||
'settings.openchamber.visual.field.themeImportInfoAria': 'Theme import info',
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.actions.reloadThemes": "Recargar temas",
|
||||
"settings.openchamber.visual.field.macVibrancy": "Transparencia de la ventana",
|
||||
"settings.openchamber.visual.field.macVibrancyHint": "Usa el desenfoque nativo de macOS (vibrancy) detrás de la barra lateral. Desactívalo para superficies totalmente sólidas y opacas.",
|
||||
"settings.openchamber.visual.field.dockBadge": "Insignia en el Dock",
|
||||
"settings.openchamber.visual.field.dockBadgeHint": "Muestra en el icono del Dock de macOS el número de chats con actividad sin ver.",
|
||||
"settings.openchamber.visual.actions.saveAndRestart": "Guardar y reiniciar",
|
||||
"settings.openchamber.visual.actions.restarting": "Reiniciando…",
|
||||
"settings.openchamber.visual.actions.reloadingThemes": "Recargando temas...",
|
||||
|
||||
@@ -1762,6 +1762,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle',
|
||||
'settings.openchamber.visual.field.macVibrancy': 'Transparence de la fenêtre',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': 'Utiliser le flou macOS natif (vibrancy) derrière la barre latérale. Désactivez cette option pour des surfaces entièrement opaques.',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Badge du Dock',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': 'Afficher sur l’icône du Dock de macOS le nombre de discussions avec une activité non vue.',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': 'Enregistrer et redémarrer',
|
||||
'settings.openchamber.visual.actions.restarting': 'Redémarrage…',
|
||||
'settings.openchamber.visual.field.fileEditorKeymap': 'Keymap de l’éditeur de fichiers',
|
||||
|
||||
@@ -1606,6 +1606,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.actions.reloadingThemes': 'テーマを再読み込み中...',
|
||||
'settings.openchamber.visual.field.macVibrancy': 'ウィンドウ透過',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': 'サイドバーの背後に macOS ネイティブのぼかし(Vibrancy)を使用します。完全に不透明なサーフェスにする場合はオフにします。',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Dock バッジ',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': '未読のアクティビティがあるチャットの数を macOS の Dock アイコンに表示します。',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': '保存して再起動',
|
||||
'settings.openchamber.visual.actions.restarting': '再起動中...',
|
||||
'settings.openchamber.visual.field.themeImportInfoAria': 'テーマインポート情報',
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.actions.reloadThemes': '테마 다시 로드',
|
||||
'settings.openchamber.visual.field.macVibrancy': '창 투명도',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': '사이드바 뒤에 macOS 기본 블러(vibrancy)를 사용합니다. 완전히 불투명한 표면을 원하면 끄세요.',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Dock 배지',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': '확인하지 않은 활동이 있는 채팅 수를 macOS Dock 아이콘에 표시합니다.',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': '저장 후 재시작',
|
||||
'settings.openchamber.visual.actions.restarting': '재시작 중…',
|
||||
'settings.openchamber.visual.actions.reloadingThemes': '테마 다시 로드 중...',
|
||||
|
||||
@@ -907,6 +907,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.actions.reloadThemes': 'Przeładuj motywy',
|
||||
'settings.openchamber.visual.field.macVibrancy': 'Przezroczystość okna',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': 'Używaj natywnego rozmycia macOS (vibrancy) za panelem bocznym. Wyłącz, aby uzyskać w pełni nieprzezroczyste powierzchnie.',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Plakietka w Docku',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': 'Pokazuj na ikonie w Docku macOS liczbę czatów z nieprzeczytaną aktywnością.',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': 'Zapisz i uruchom ponownie',
|
||||
'settings.openchamber.visual.actions.restarting': 'Ponowne uruchamianie…',
|
||||
'settings.openchamber.visual.actions.reloadingThemes': 'Przeładowywanie motywów...',
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.actions.reloadThemes": "Recarregar temas",
|
||||
"settings.openchamber.visual.field.macVibrancy": "Transparência da janela",
|
||||
"settings.openchamber.visual.field.macVibrancyHint": "Usa o desfoque nativo do macOS (vibrancy) atrás da barra lateral. Desative para superfícies totalmente sólidas e opacas.",
|
||||
"settings.openchamber.visual.field.dockBadge": "Selo no Dock",
|
||||
"settings.openchamber.visual.field.dockBadgeHint": "Mostra no ícone do Dock do macOS o número de conversas com atividade não vista.",
|
||||
"settings.openchamber.visual.actions.saveAndRestart": "Salvar e reiniciar",
|
||||
"settings.openchamber.visual.actions.restarting": "Reiniciando…",
|
||||
"settings.openchamber.visual.actions.reloadingThemes": "Recarregando temas...",
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.actions.reloadThemes": "Перезавантажити теми",
|
||||
"settings.openchamber.visual.field.macVibrancy": "Прозорість вікна",
|
||||
"settings.openchamber.visual.field.macVibrancyHint": "Використовувати нативне розмиття macOS (vibrancy) під сайдбаром. Вимкніть для повністю непрозорих поверхонь.",
|
||||
"settings.openchamber.visual.field.dockBadge": "Лічильник у доку",
|
||||
"settings.openchamber.visual.field.dockBadgeHint": "Показувати на іконці в доку кількість чатів із непрочитаною активністю.",
|
||||
"settings.openchamber.visual.actions.saveAndRestart": "Зберегти та перезапустити",
|
||||
"settings.openchamber.visual.actions.restarting": "Перезапуск…",
|
||||
"settings.openchamber.visual.actions.reloadingThemes": "Перезавантаження тем...",
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.actions.reloadThemes': '重新加载主题',
|
||||
'settings.openchamber.visual.field.macVibrancy': '窗口透明度',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': '在侧边栏后使用 macOS 原生模糊(vibrancy)。关闭以获得完全不透明的界面。',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Dock 角标',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': '在 macOS Dock 图标上显示有未读动态的会话数量。',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': '保存并重启',
|
||||
'settings.openchamber.visual.actions.restarting': '正在重启…',
|
||||
'settings.openchamber.visual.actions.reloadingThemes': '正在重新加载主题...',
|
||||
|
||||
@@ -1491,6 +1491,8 @@
|
||||
'settings.openchamber.visual.actions.reloadThemes': '重新載入主題',
|
||||
'settings.openchamber.visual.field.macVibrancy': '視窗透明度',
|
||||
'settings.openchamber.visual.field.macVibrancyHint': '在側邊欄後使用 macOS 原生模糊(vibrancy)。關閉以獲得完全不透明的介面。',
|
||||
'settings.openchamber.visual.field.dockBadge': 'Dock 標記',
|
||||
'settings.openchamber.visual.field.dockBadgeHint': '在 macOS Dock 圖示上顯示有未讀動態的對話數量。',
|
||||
'settings.openchamber.visual.actions.saveAndRestart': '儲存並重新啟動',
|
||||
'settings.openchamber.visual.actions.restarting': '正在重新啟動…',
|
||||
'settings.openchamber.visual.actions.reloadingThemes': '正在重新載入主題...',
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SettingsSearchResult extends SettingsSearchItem {
|
||||
interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
|
||||
isMobile: boolean;
|
||||
isDesktopLocalOrigin: boolean;
|
||||
// macOS desktop shell — for controls that only render on darwin (e.g. dock badge).
|
||||
isMac: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
@@ -65,6 +67,16 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
keywords: ['transparent', 'transparency', 'vibrancy', 'blur', 'macos', 'opaque'],
|
||||
isAvailable: (ctx) => ctx.isDesktopLocalOrigin,
|
||||
},
|
||||
{
|
||||
id: 'appearance.dock-badge',
|
||||
page: 'appearance',
|
||||
titleKey: 'settings.openchamber.visual.field.dockBadge',
|
||||
descriptionKey: 'settings.openchamber.visual.field.dockBadgeHint',
|
||||
keywords: ['dock', 'badge', 'unread', 'unseen', 'counter', 'count', 'notification', 'macos'],
|
||||
// Exactly matches the render guard in OpenChamberVisualSettings: any darwin
|
||||
// Electron shell (isMac already implies isDesktopShell), local or remote host.
|
||||
isAvailable: (ctx) => ctx.isMac,
|
||||
},
|
||||
{
|
||||
id: 'appearance.pwa-install-name',
|
||||
page: 'appearance',
|
||||
|
||||
@@ -592,6 +592,8 @@ interface UIStore {
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks: boolean;
|
||||
// Desktop dock badge showing the count of sessions with unseen activity (macOS).
|
||||
dockBadgeEnabled: boolean;
|
||||
|
||||
// Event toggles (which events trigger notifications)
|
||||
notifyOnCompletion: boolean;
|
||||
@@ -750,6 +752,7 @@ interface UIStore {
|
||||
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
||||
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
|
||||
setNotifyOnSubtasks: (value: boolean) => void;
|
||||
setDockBadgeEnabled: (value: boolean) => void;
|
||||
setNotifyOnCompletion: (value: boolean) => void;
|
||||
setNotifyOnError: (value: boolean) => void;
|
||||
setNotifyOnQuestion: (value: boolean) => void;
|
||||
@@ -877,6 +880,7 @@ export const useUIStore = create<UIStore>()(
|
||||
nativeNotificationsEnabled: false,
|
||||
notificationMode: 'hidden-only',
|
||||
notifyOnSubtasks: true,
|
||||
dockBadgeEnabled: true,
|
||||
|
||||
// Event toggles (which events trigger notifications)
|
||||
notifyOnCompletion: true,
|
||||
@@ -1975,6 +1979,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ notifyOnSubtasks: value });
|
||||
},
|
||||
|
||||
setDockBadgeEnabled: (value) => {
|
||||
set({ dockBadgeEnabled: value });
|
||||
},
|
||||
|
||||
setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); },
|
||||
setNotifyOnError: (value) => { set({ notifyOnError: value }); },
|
||||
setNotifyOnQuestion: (value) => { set({ notifyOnQuestion: value }); },
|
||||
@@ -2243,6 +2251,7 @@ export const useUIStore = create<UIStore>()(
|
||||
notificationMode: state.notificationMode,
|
||||
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||
dockBadgeEnabled: state.dockBadgeEnabled,
|
||||
notifyOnCompletion: state.notifyOnCompletion,
|
||||
notifyOnError: state.notifyOnError,
|
||||
notifyOnQuestion: state.notifyOnQuestion,
|
||||
|
||||
Reference in New Issue
Block a user