mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(desktop): tray unread dot and monochrome tray icon (#3477)
This commit is contained in:
1 parent
36095ab66f
commit
b3f373c94c
51 files changed
+1076
-47
No files matched your search
@@ -57,6 +57,7 @@ export type ChromiumSwitchesSetting = ReadonlyArray<string> | Record<string, unk
|
||||
|
||||
interface PersistedDesktopWindowBehaviorSettings {
|
||||
showTrayIcon?: boolean;
|
||||
coloredTrayIcon?: boolean;
|
||||
useNativeTitleBar?: boolean;
|
||||
minimizeToTrayV2?: boolean;
|
||||
closeToTrayV2?: boolean;
|
||||
@@ -87,6 +88,7 @@ function getDefaultDesktopTroubleshootingSettings(): DesktopTroubleshootingSetti
|
||||
function getDefaultDesktopWindowBehaviorSettings(): DesktopWindowBehaviorSettings {
|
||||
return {
|
||||
showTrayIcon: true,
|
||||
coloredTrayIcon: false,
|
||||
minimizeToTray: false,
|
||||
closeToTray: true,
|
||||
startMinimized: false,
|
||||
@@ -112,6 +114,9 @@ function sanitizePersistedDesktopWindowBehaviorSettings(
|
||||
if (typeof value.showTrayIcon === 'boolean') {
|
||||
settings.showTrayIcon = value.showTrayIcon;
|
||||
}
|
||||
if (typeof value.coloredTrayIcon === 'boolean') {
|
||||
settings.coloredTrayIcon = value.coloredTrayIcon;
|
||||
}
|
||||
if (typeof value.useNativeTitleBar === 'boolean') {
|
||||
settings.useNativeTitleBar = value.useNativeTitleBar;
|
||||
}
|
||||
@@ -321,6 +326,10 @@ function normalizeDesktopWindowBehaviorSettings(
|
||||
const normalized = {
|
||||
showTrayIcon:
|
||||
typeof normalizedSettings?.showTrayIcon === 'boolean' ? normalizedSettings.showTrayIcon : defaults.showTrayIcon,
|
||||
coloredTrayIcon:
|
||||
typeof normalizedSettings?.coloredTrayIcon === 'boolean'
|
||||
? normalizedSettings.coloredTrayIcon
|
||||
: defaults.coloredTrayIcon,
|
||||
minimizeToTray:
|
||||
typeof normalizedSettings?.minimizeToTray === 'boolean'
|
||||
? normalizedSettings.minimizeToTray
|
||||
@@ -395,6 +404,7 @@ function serializeDesktopWindowBehaviorSettings(
|
||||
): PersistedDesktopWindowBehaviorSettings {
|
||||
return {
|
||||
showTrayIcon: settings.showTrayIcon,
|
||||
coloredTrayIcon: settings.coloredTrayIcon,
|
||||
useNativeTitleBar: settings.useNativeTitleBar,
|
||||
rememberWindowState: settings.rememberWindowState,
|
||||
allowTransparency: settings.allowTransparency,
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface DesktopInfo {
|
||||
|
||||
export interface DesktopWindowBehaviorSettings {
|
||||
showTrayIcon: boolean;
|
||||
coloredTrayIcon: boolean;
|
||||
minimizeToTray: boolean;
|
||||
closeToTray: boolean;
|
||||
startMinimized: boolean;
|
||||
@@ -303,6 +304,7 @@ export interface ElectronAPI {
|
||||
closeNotifications: (ids: Array<string>) => void;
|
||||
onNotificationClick: (callback: (id: string, url?: string) => void) => () => void;
|
||||
setBadgeCount: (count: number) => void;
|
||||
setUnreadBadge: (badge: number) => void;
|
||||
setNativeLocale: (locale: string, strings: Record<string, string>) => void;
|
||||
flashFrame: (persistent?: boolean) => void;
|
||||
stopFlashFrame: () => void;
|
||||
|
||||
@@ -231,6 +231,7 @@ export async function getDesktopDebugInfo(
|
||||
function formatWindowBehavior(settings: DesktopWindowBehaviorSettings): string {
|
||||
return [
|
||||
`showTrayIcon=${settings.showTrayIcon}`,
|
||||
`coloredTrayIcon=${settings.coloredTrayIcon}`,
|
||||
`minimizeToTray=${settings.minimizeToTray}`,
|
||||
`closeToTray=${settings.closeToTray}`,
|
||||
`startMinimized=${settings.startMinimized}`,
|
||||
|
||||
@@ -16,7 +16,9 @@ import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/m
|
||||
import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions';
|
||||
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
||||
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
||||
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
|
||||
import {renderTrayIcon, resolveTrayIconAppearance} from '@electron/main/TrayIconImage';
|
||||
import {describeUnreadBadge} from '@electron/main/UnreadBadge';
|
||||
import {app, type BrowserWindow, clipboard, Menu, nativeImage, nativeTheme, Tray} from 'electron';
|
||||
|
||||
const logger = createChildLogger('DesktopTray');
|
||||
const ICON_DIR_NAMES: Record<BuildChannel, string> = {
|
||||
@@ -53,6 +55,10 @@ let pendingTrayActionFlush: NodeJS.Timeout | null = null;
|
||||
let trayActionBridgeWebContentsId: number | null = null;
|
||||
let trayForcedExitTimer: NodeJS.Timeout | null = null;
|
||||
let pendingTrayActions: Array<TrayActionPayload> = [];
|
||||
let trayUnreadBadge = 0;
|
||||
let trayBaseIcon: Electron.NativeImage | null = null;
|
||||
let appliedTrayIconKey: string | null = null;
|
||||
const trayIconCache = new Map<string, Electron.NativeImage>();
|
||||
|
||||
const BIDI_FIRST_STRONG_ISOLATE = '\u2068';
|
||||
const BIDI_POP_DIRECTIONAL_ISOLATE = '\u2069';
|
||||
@@ -240,6 +246,47 @@ function createTrayIcon(): Electron.NativeImage | null {
|
||||
return createTrayImage(['256x256.png', '512x512.png', '128x128.png', '64x64.png', '48x48.png', 'icon.png']);
|
||||
}
|
||||
|
||||
function resolveTrayIcon(): {key: string; image: Electron.NativeImage} | null {
|
||||
trayBaseIcon ??= createTrayIcon();
|
||||
if (!trayBaseIcon) return null;
|
||||
const appearance = resolveTrayIconAppearance({
|
||||
platform: process.platform,
|
||||
coloredTrayIcon: getDesktopWindowBehaviorSettings().coloredTrayIcon,
|
||||
darkSystemUI: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI,
|
||||
});
|
||||
const indicator = trayUnreadBadge !== 0;
|
||||
const key = `${appearance}:${indicator}`;
|
||||
let image = trayIconCache.get(key);
|
||||
if (!image) {
|
||||
image = renderTrayIcon(trayBaseIcon, appearance, indicator);
|
||||
trayIconCache.set(key, image);
|
||||
}
|
||||
return {key, image};
|
||||
}
|
||||
|
||||
function getTrayToolTip(): string {
|
||||
const description = describeUnreadBadge(trayUnreadBadge);
|
||||
return description ? `${APP_NAME} - ${description}` : APP_NAME;
|
||||
}
|
||||
|
||||
function syncTrayIcon(force: boolean): void {
|
||||
if (!tray || tray.isDestroyed()) return;
|
||||
const icon = resolveTrayIcon();
|
||||
if (!icon) {
|
||||
logger.warn('Tray icon asset could not be reloaded; keeping existing tray image');
|
||||
} else if (force || icon.key !== appliedTrayIconKey) {
|
||||
tray.setImage(icon.image);
|
||||
appliedTrayIconKey = icon.key;
|
||||
}
|
||||
tray.setToolTip(getTrayToolTip());
|
||||
}
|
||||
|
||||
export function setDesktopTrayUnreadBadge(badge: number): void {
|
||||
if (badge === trayUnreadBadge) return;
|
||||
trayUnreadBadge = badge;
|
||||
syncTrayIcon(false);
|
||||
}
|
||||
|
||||
function ensureMainWindowVisible(): void {
|
||||
if (!controller) return;
|
||||
const mainWindow = controller.getMainWindow();
|
||||
@@ -434,14 +481,16 @@ function buildTrayMenu(): Menu {
|
||||
}
|
||||
|
||||
function createTray(): Tray | null {
|
||||
const trayIcon = createTrayIcon();
|
||||
const trayIcon = resolveTrayIcon();
|
||||
if (!trayIcon) {
|
||||
logger.warn('Tray icon asset could not be resolved; tray disabled');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const nextTray = process.platform === 'darwin' ? new Tray(trayIcon, TRAY_POSITION_GUID) : new Tray(trayIcon);
|
||||
nextTray.setToolTip(APP_NAME);
|
||||
const nextTray =
|
||||
process.platform === 'darwin' ? new Tray(trayIcon.image, TRAY_POSITION_GUID) : new Tray(trayIcon.image);
|
||||
appliedTrayIconKey = trayIcon.key;
|
||||
nextTray.setToolTip(getTrayToolTip());
|
||||
nextTray.setContextMenu(buildTrayMenu());
|
||||
if (process.platform !== 'darwin') {
|
||||
nextTray.on('click', toggleMainWindow);
|
||||
@@ -520,13 +569,7 @@ export function applyDesktopWindowBehaviorSettings(): void {
|
||||
tray = createTray();
|
||||
}
|
||||
} else {
|
||||
const nextTrayIcon = createTrayIcon();
|
||||
if (nextTrayIcon) {
|
||||
tray.setImage(nextTrayIcon);
|
||||
} else {
|
||||
logger.warn('Tray icon asset could not be reloaded; keeping existing tray image');
|
||||
}
|
||||
tray.setToolTip(APP_NAME);
|
||||
syncTrayIcon(true);
|
||||
}
|
||||
refreshDesktopTrayMenu();
|
||||
revealHiddenWindowIfTrayUnavailable();
|
||||
@@ -546,10 +589,13 @@ export function initializeDesktopTray(nextController: DesktopTrayController): vo
|
||||
trayLocaleSubscribed = true;
|
||||
onLocaleChange(() => {
|
||||
if (tray && !tray.isDestroyed()) {
|
||||
tray.setToolTip(APP_NAME);
|
||||
tray.setToolTip(getTrayToolTip());
|
||||
}
|
||||
refreshDesktopTrayMenu();
|
||||
});
|
||||
if (process.platform === 'win32') {
|
||||
nativeTheme.on('updated', () => syncTrayIcon(false));
|
||||
}
|
||||
observeDesktopUpdateState(refreshDesktopTrayMenu);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {describe, test} from 'node:test';
|
||||
import {installElectronStub, installTestModuleStub} from './LocalAppTestSupport.test.mjs';
|
||||
import {fakeNativeImageModule, pixel} from './TrayIconTestSupport.test.mjs';
|
||||
|
||||
const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-desktop-tray-icon-test-'));
|
||||
fs.mkdirSync(path.join(resourcesPath, 'icons'));
|
||||
fs.writeFileSync(path.join(resourcesPath, 'icons', '16x16.png'), '');
|
||||
Object.defineProperty(process, 'resourcesPath', {value: resourcesPath, configurable: true});
|
||||
Object.defineProperty(process, 'platform', {value: 'win32', configurable: true});
|
||||
|
||||
const settings = {showTrayIcon: true, coloredTrayIcon: false};
|
||||
globalThis.__fluxerDesktopTrayIconTestSettings__ = settings;
|
||||
|
||||
installTestModuleStub(
|
||||
'@electron/common/DesktopConfig',
|
||||
'export const getDesktopWindowBehaviorSettings = () => ({...globalThis.__fluxerDesktopTrayIconTestSettings__});',
|
||||
);
|
||||
installTestModuleStub(
|
||||
'@electron/main/DesktopUpdatePrompt',
|
||||
'export const checkForUpdatesFromShell = async () => {}; export const startDesktopUpdateFromShell = () => {};',
|
||||
);
|
||||
installTestModuleStub('@electron/main/LinuxLaunchPath', 'export const relaunchStableLaunchPath = () => {};');
|
||||
|
||||
const trays = [];
|
||||
|
||||
class FakeTray {
|
||||
constructor(image) {
|
||||
this.images = [image];
|
||||
this.toolTips = [];
|
||||
this.destroyed = false;
|
||||
trays.push(this);
|
||||
}
|
||||
|
||||
get image() {
|
||||
return this.images.at(-1);
|
||||
}
|
||||
|
||||
setImage(image) {
|
||||
this.images.push(image);
|
||||
}
|
||||
|
||||
setToolTip(toolTip) {
|
||||
this.toolTips.push(toolTip);
|
||||
}
|
||||
|
||||
setContextMenu() {}
|
||||
|
||||
on() {}
|
||||
|
||||
removeAllListeners() {}
|
||||
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const themeListeners = [];
|
||||
const nativeTheme = {
|
||||
shouldUseDarkColorsForSystemIntegratedUI: true,
|
||||
on: (event, listener) => {
|
||||
if (event === 'updated') themeListeners.push(listener);
|
||||
},
|
||||
};
|
||||
|
||||
installElectronStub({
|
||||
nativeImage: fakeNativeImageModule,
|
||||
nativeTheme,
|
||||
Tray: FakeTray,
|
||||
Menu: {buildFromTemplate: () => ({})},
|
||||
clipboard: {},
|
||||
app: {getPath: () => resourcesPath},
|
||||
});
|
||||
|
||||
const {applyDesktopWindowBehaviorSettings, initializeDesktopTray, setDesktopTrayUnreadBadge} = await import(
|
||||
'./DesktopTray.ts'
|
||||
);
|
||||
|
||||
const controller = {
|
||||
createWindow: () => null,
|
||||
getMainWindow: () => null,
|
||||
isMainWindowTakenOver: () => false,
|
||||
hideWindow: () => {},
|
||||
setQuitting: () => {},
|
||||
showWindow: () => {},
|
||||
};
|
||||
|
||||
function topRight(image) {
|
||||
return pixel(image.toBitmap(), 16, 12, 3);
|
||||
}
|
||||
|
||||
describe('desktop tray icon state', () => {
|
||||
test('follows the unread badge, theme and colored setting, and survives tray recreation', () => {
|
||||
initializeDesktopTray(controller);
|
||||
const [tray] = trays;
|
||||
const plainWhite = tray.image;
|
||||
assert.deepEqual(topRight(plainWhite), [0xff, 0xff, 0xff, 0xff], 'white glyph on a dark taskbar');
|
||||
assert.match(tray.toolTips.at(-1), /^Fluxer[^-]*$/);
|
||||
|
||||
setDesktopTrayUnreadBadge(3);
|
||||
assert.equal(tray.images.length, 2);
|
||||
assert.deepEqual(topRight(tray.image), [0x00, 0x00, 0xdd, 0xff], 'red dot');
|
||||
assert.match(tray.toolTips.at(-1), / - Unread messages: 3$/);
|
||||
|
||||
const dotted = tray.image;
|
||||
setDesktopTrayUnreadBadge(4);
|
||||
setDesktopTrayUnreadBadge(4);
|
||||
setDesktopTrayUnreadBadge(-1);
|
||||
assert.equal(tray.images.length, 2, 'the image is only set when the dot appears or disappears');
|
||||
assert.match(tray.toolTips.at(-1), / - Unread messages$/);
|
||||
|
||||
setDesktopTrayUnreadBadge(0);
|
||||
assert.equal(tray.image, plainWhite, 'clearing reuses the cached plain image');
|
||||
setDesktopTrayUnreadBadge(2);
|
||||
assert.equal(tray.image, dotted, 'the dotted image is cached too');
|
||||
|
||||
nativeTheme.shouldUseDarkColorsForSystemIntegratedUI = false;
|
||||
for (const listener of themeListeners) listener();
|
||||
assert.deepEqual(topRight(tray.image), [0x00, 0x00, 0xdd, 0xff]);
|
||||
assert.deepEqual(pixel(tray.image.toBitmap(), 16, 2, 8), [0x1c, 0x1c, 0x1c, 0xff], 'dark glyph on a light taskbar');
|
||||
|
||||
settings.coloredTrayIcon = true;
|
||||
applyDesktopWindowBehaviorSettings();
|
||||
assert.deepEqual(pixel(tray.image.toBitmap(), 16, 2, 8), [0xd9, 0x41, 0x46, 0xff], 'colored icon');
|
||||
assert.deepEqual(topRight(tray.image), [0x00, 0x00, 0xdd, 0xff], 'the dot stays on the colored icon');
|
||||
|
||||
settings.showTrayIcon = false;
|
||||
applyDesktopWindowBehaviorSettings();
|
||||
assert.equal(tray.destroyed, true);
|
||||
setDesktopTrayUnreadBadge(0);
|
||||
settings.showTrayIcon = true;
|
||||
applyDesktopWindowBehaviorSettings();
|
||||
assert.equal(trays.length, 2);
|
||||
assert.deepEqual(trays[1].images.length, 1);
|
||||
assert.deepEqual(pixel(trays[1].image.toBitmap(), 16, 2, 8), [0xd9, 0x41, 0x46, 0xff]);
|
||||
assert.notDeepEqual(topRight(trays[1].image), [0x00, 0x00, 0xdd, 0xff], 'a recreated tray shows the current state');
|
||||
setDesktopTrayUnreadBadge(1);
|
||||
assert.deepEqual(topRight(trays[1].image), [0x00, 0x00, 0xdd, 0xff]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {describe, test} from 'node:test';
|
||||
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
|
||||
|
||||
installElectronStub();
|
||||
|
||||
const {getDesktopWindowBehaviorSettings, loadDesktopConfig, setDesktopWindowBehaviorSettings} = await import(
|
||||
'../common/DesktopConfig.ts'
|
||||
);
|
||||
|
||||
function loadSettings(windowBehavior) {
|
||||
const userDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-desktop-tray-setting-test-'));
|
||||
fs.writeFileSync(path.join(userDataPath, 'settings.json'), JSON.stringify({window_behavior: windowBehavior}));
|
||||
loadDesktopConfig(userDataPath);
|
||||
return userDataPath;
|
||||
}
|
||||
|
||||
function readPersisted(userDataPath) {
|
||||
return JSON.parse(fs.readFileSync(path.join(userDataPath, 'settings.json'), 'utf8')).window_behavior;
|
||||
}
|
||||
|
||||
describe('colored tray icon setting', () => {
|
||||
test('defaults to the monochrome icon for existing and new configs', () => {
|
||||
loadSettings(undefined);
|
||||
assert.equal(getDesktopWindowBehaviorSettings().coloredTrayIcon, false);
|
||||
loadSettings({showTrayIcon: true});
|
||||
assert.equal(getDesktopWindowBehaviorSettings().coloredTrayIcon, false);
|
||||
});
|
||||
|
||||
test('drops values that are not booleans', () => {
|
||||
for (const value of ['true', 1, null, {}]) {
|
||||
loadSettings({coloredTrayIcon: value});
|
||||
assert.equal(getDesktopWindowBehaviorSettings().coloredTrayIcon, false, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('persists and reloads the choice without touching the tray visibility', () => {
|
||||
const userDataPath = loadSettings({showTrayIcon: false});
|
||||
assert.equal(setDesktopWindowBehaviorSettings({coloredTrayIcon: true}).coloredTrayIcon, true);
|
||||
assert.equal(readPersisted(userDataPath).coloredTrayIcon, true);
|
||||
loadDesktopConfig(userDataPath);
|
||||
const settings = getDesktopWindowBehaviorSettings();
|
||||
assert.equal(settings.coloredTrayIcon, true);
|
||||
assert.equal(settings.showTrayIcon, false);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
applyDesktopWindowBehaviorSettings,
|
||||
desktopTrayChangePendingRestart,
|
||||
hasActiveDesktopTray,
|
||||
setDesktopTrayUnreadBadge,
|
||||
updateTrayRuntimeState,
|
||||
} from '@electron/main/DesktopTray';
|
||||
import {getDesktopUpdateState, observeDesktopUpdateState, startDesktopUpdate} from '@electron/main/DesktopUpdateGate';
|
||||
@@ -57,6 +58,7 @@ import {registerPasskeyHandlers} from '@electron/main/Passkeys';
|
||||
import {getAppMetricsSnapshot, getDesktopInfo, getGpuInfo} from '@electron/main/PlatformInfo';
|
||||
import {
|
||||
createPrivilegedRendererDocumentOwners,
|
||||
isPrivilegedRendererDocumentSender,
|
||||
requirePrivilegedRendererDocumentSender,
|
||||
} from '@electron/main/PrivilegedRendererDocuments';
|
||||
import {getDesktopSelectedInstanceClient} from '@electron/main/SelectedInstanceFetch';
|
||||
@@ -72,6 +74,7 @@ import {
|
||||
setHardwareAccelerationDisabled,
|
||||
setHardwareAccelerationDisabledAndRestart,
|
||||
} from '@electron/main/Troubleshooting';
|
||||
import {parseUnreadBadge, UNREAD_BADGE_DOT} from '@electron/main/UnreadBadge';
|
||||
import {registerVoiceBackgroundMediaCacheHandlers} from '@electron/main/VoiceBackgroundMediaCache';
|
||||
import {
|
||||
focusVoiceDebugEventSinkPopout,
|
||||
@@ -119,6 +122,9 @@ function normalizeDesktopWindowBehaviorUpdate(value: unknown): Partial<DesktopWi
|
||||
if (typeof value.showTrayIcon === 'boolean') {
|
||||
update.showTrayIcon = value.showTrayIcon;
|
||||
}
|
||||
if (typeof value.coloredTrayIcon === 'boolean') {
|
||||
update.coloredTrayIcon = value.coloredTrayIcon;
|
||||
}
|
||||
if (typeof value.minimizeToTray === 'boolean') {
|
||||
update.minimizeToTray = value.minimizeToTray;
|
||||
}
|
||||
@@ -154,6 +160,17 @@ function getActiveMiddleClickAutoscroll(): boolean {
|
||||
return process.platform === 'linux' && hasEnabledBlinkFeature(MIDDLE_CLICK_AUTOSCROLL_BLINK_FEATURE);
|
||||
}
|
||||
|
||||
function applyUnreadBadge(badge: number): void {
|
||||
if (process.platform === 'win32') {
|
||||
setWindowsBadgeOverlay(getMainWindow(), badge);
|
||||
} else if (process.platform === 'darwin' && badge === UNREAD_BADGE_DOT) {
|
||||
app.dock?.setBadge('•');
|
||||
} else {
|
||||
app.setBadgeCount(Math.max(badge, 0));
|
||||
}
|
||||
setDesktopTrayUnreadBadge(badge);
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
registerVoiceDebugEventSinkPopoutIpcHandlers();
|
||||
registerVoiceBackgroundMediaCacheHandlers();
|
||||
@@ -467,14 +484,15 @@ export function registerIpcHandlers(): void {
|
||||
if (surface === 'input-monitoring' && status === 'granted') retryBlockedGlobalShortcutHooks();
|
||||
},
|
||||
});
|
||||
ipcMain.on('set-badge-count', (_event, count: number) => {
|
||||
if (process.platform === 'darwin') {
|
||||
app.setBadgeCount(count);
|
||||
} else if (process.platform === 'win32') {
|
||||
setWindowsBadgeOverlay(getMainWindow(), count);
|
||||
} else {
|
||||
app.setBadgeCount(count);
|
||||
}
|
||||
ipcMain.on('set-badge-count', (event, count: unknown) => {
|
||||
const badge = parseUnreadBadge(count);
|
||||
if (badge === null || !isPrivilegedRendererDocumentSender(event)) return;
|
||||
applyUnreadBadge(Math.max(badge, 0));
|
||||
});
|
||||
ipcMain.on('set-unread-badge', (event, value: unknown) => {
|
||||
const badge = parseUnreadBadge(value);
|
||||
if (badge === null || !isPrivilegedRendererDocumentSender(event)) return;
|
||||
applyUnreadBadge(badge);
|
||||
});
|
||||
ipcMain.on('set-zoom-factor', (event, factor: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {describe, test} from 'node:test';
|
||||
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
|
||||
import {BLUE_BGRA, discBitmap, FakeNativeImage, fakeNativeImageModule, pixel} from './TrayIconTestSupport.test.mjs';
|
||||
|
||||
installElectronStub({nativeImage: fakeNativeImageModule});
|
||||
|
||||
const {paintMonochromeGlyph, paintTrayIndicatorDot, renderTrayIcon, resolveTrayIconAppearance} = await import(
|
||||
'./TrayIconImage.ts'
|
||||
);
|
||||
|
||||
describe('tray icon appearance', () => {
|
||||
test('macOS always uses the template image', () => {
|
||||
for (const coloredTrayIcon of [false, true]) {
|
||||
for (const darkSystemUI of [false, true]) {
|
||||
assert.equal(resolveTrayIconAppearance({platform: 'darwin', coloredTrayIcon, darkSystemUI}), 'template');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows follows the taskbar theme unless the colored icon is chosen', () => {
|
||||
assert.equal(resolveTrayIconAppearance({platform: 'win32', coloredTrayIcon: false, darkSystemUI: true}), 'white');
|
||||
assert.equal(resolveTrayIconAppearance({platform: 'win32', coloredTrayIcon: false, darkSystemUI: false}), 'black');
|
||||
assert.equal(resolveTrayIconAppearance({platform: 'win32', coloredTrayIcon: true, darkSystemUI: false}), 'colored');
|
||||
});
|
||||
|
||||
test('Linux uses a white glyph whatever the system theme says', () => {
|
||||
assert.equal(resolveTrayIconAppearance({platform: 'linux', coloredTrayIcon: false, darkSystemUI: false}), 'white');
|
||||
assert.equal(resolveTrayIconAppearance({platform: 'linux', coloredTrayIcon: true, darkSystemUI: false}), 'colored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monochrome glyph', () => {
|
||||
test('keeps the body, cuts out the light mark and leaves the outside transparent', () => {
|
||||
const bitmap = discBitmap(32);
|
||||
paintMonochromeGlyph(bitmap, [0xff, 0xff, 0xff]);
|
||||
assert.deepEqual(pixel(bitmap, 32, 16, 6), [0xff, 0xff, 0xff, 0xff]);
|
||||
assert.deepEqual(pixel(bitmap, 32, 16, 16), [0, 0, 0, 0]);
|
||||
assert.deepEqual(pixel(bitmap, 32, 0, 0), [0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('tints the glyph with premultiplied color', () => {
|
||||
const bitmap = discBitmap(32);
|
||||
paintMonochromeGlyph(bitmap, [0x1c, 0x1c, 0x1c]);
|
||||
assert.deepEqual(pixel(bitmap, 32, 16, 6), [0x1c, 0x1c, 0x1c, 0xff]);
|
||||
});
|
||||
|
||||
test('an icon without a light mark keeps its whole silhouette', () => {
|
||||
const bitmap = Buffer.alloc(4 * 4 * 4);
|
||||
for (let offset = 0; offset < bitmap.length; offset += 4) bitmap.set(BLUE_BGRA, offset);
|
||||
bitmap.set([0, 0, 0, 0], 0);
|
||||
paintMonochromeGlyph(bitmap, [0xff, 0xff, 0xff]);
|
||||
assert.deepEqual(pixel(bitmap, 4, 0, 0), [0, 0, 0, 0]);
|
||||
assert.deepEqual(pixel(bitmap, 4, 2, 2), [0xff, 0xff, 0xff, 0xff]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unread dot', () => {
|
||||
test('draws an opaque dot in the top-right corner with a transparent gap around it', () => {
|
||||
const size = 64;
|
||||
const bitmap = Buffer.alloc(size * size * 4, 0xff);
|
||||
paintTrayIndicatorDot(bitmap, size, size, [0x00, 0x00, 0xdd]);
|
||||
const radius = size * 0.22;
|
||||
assert.deepEqual(pixel(bitmap, size, Math.floor(size - radius), Math.floor(radius)), [0x00, 0x00, 0xdd, 0xff]);
|
||||
assert.deepEqual(pixel(bitmap, size, Math.floor(size - radius), Math.floor(radius * 2 + 2)), [0, 0, 0, 0]);
|
||||
assert.deepEqual(pixel(bitmap, size, 4, size - 4), [0xff, 0xff, 0xff, 0xff]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTrayIcon', () => {
|
||||
test('returns the base image untouched for the colored icon without a dot', () => {
|
||||
const base = FakeNativeImage.fromBitmap(16);
|
||||
assert.equal(renderTrayIcon(base, 'colored', false), base);
|
||||
const template = FakeNativeImage.fromBitmap(16);
|
||||
template.setTemplateImage(true);
|
||||
assert.equal(renderTrayIcon(template, 'template', false), template);
|
||||
});
|
||||
|
||||
test('paints every scale factor and keeps the template flag', () => {
|
||||
const base = FakeNativeImage.fromBitmap(16);
|
||||
base.addRepresentation({scaleFactor: 2, width: 32, height: 32, buffer: discBitmap(32)});
|
||||
base.setTemplateImage(true);
|
||||
const result = renderTrayIcon(base, 'template', true);
|
||||
assert.notEqual(result, base);
|
||||
assert.deepEqual(result.getScaleFactors(), [1, 2]);
|
||||
assert.equal(result.isTemplateImage(), true);
|
||||
assert.deepEqual(pixel(result.toBitmap({scaleFactor: 2}), 32, 25, 7), [0, 0, 0, 0xff]);
|
||||
assert.deepEqual(pixel(base.toBitmap({scaleFactor: 2}), 32, 25, 7), BLUE_BGRA, 'the base image is not mutated');
|
||||
});
|
||||
|
||||
test('combines the monochrome glyph with a red dot', () => {
|
||||
const result = renderTrayIcon(FakeNativeImage.fromBitmap(32), 'white', true);
|
||||
assert.equal(result.isTemplateImage(), false);
|
||||
const bitmap = result.toBitmap();
|
||||
assert.deepEqual(pixel(bitmap, 32, 25, 7), [0x00, 0x00, 0xdd, 0xff]);
|
||||
assert.deepEqual(pixel(bitmap, 32, 6, 16), [0xff, 0xff, 0xff, 0xff]);
|
||||
});
|
||||
|
||||
test('skips representations whose bitmap does not match their size', () => {
|
||||
const base = FakeNativeImage.fromBitmap(16);
|
||||
base.toBitmap = () => Buffer.alloc(3);
|
||||
assert.equal(renderTrayIcon(base, 'white', true), base);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type NativeImage, nativeImage} from 'electron';
|
||||
|
||||
export type TrayIconAppearance = 'colored' | 'template' | 'white' | 'black';
|
||||
|
||||
type Bgr = Readonly<[number, number, number]>;
|
||||
|
||||
const DOT_RADIUS_RATIO = 0.22;
|
||||
const DOT_GAP_RATIO = 0.0625;
|
||||
const SUBSAMPLES = 4;
|
||||
const MIN_GLYPH_CONTRAST = 32;
|
||||
const GLYPH_CUTOUT_START = 0.1;
|
||||
const GLYPH_CUTOUT_END = 0.5;
|
||||
const DOT_BGR: Bgr = [0x00, 0x00, 0xdd];
|
||||
const TEMPLATE_DOT_BGR: Bgr = [0x00, 0x00, 0x00];
|
||||
const GLYPH_BGR: Readonly<Record<'white' | 'black', Bgr>> = {
|
||||
white: [0xff, 0xff, 0xff],
|
||||
black: [0x1c, 0x1c, 0x1c],
|
||||
};
|
||||
|
||||
export function resolveTrayIconAppearance(options: {
|
||||
platform: NodeJS.Platform;
|
||||
coloredTrayIcon: boolean;
|
||||
darkSystemUI: boolean;
|
||||
}): TrayIconAppearance {
|
||||
if (options.platform === 'darwin') return 'template';
|
||||
if (options.coloredTrayIcon) return 'colored';
|
||||
if (options.platform === 'win32' && !options.darkSystemUI) return 'black';
|
||||
return 'white';
|
||||
}
|
||||
|
||||
function coverage(x: number, y: number, centerX: number, centerY: number, radius: number): number {
|
||||
let hits = 0;
|
||||
for (let sy = 0; sy < SUBSAMPLES; sy++) {
|
||||
const dy = y + (sy + 0.5) / SUBSAMPLES - centerY;
|
||||
for (let sx = 0; sx < SUBSAMPLES; sx++) {
|
||||
const dx = x + (sx + 0.5) / SUBSAMPLES - centerX;
|
||||
if (dx * dx + dy * dy <= radius * radius) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits / (SUBSAMPLES * SUBSAMPLES);
|
||||
}
|
||||
|
||||
function luminance(bitmap: Buffer, offset: number): number {
|
||||
const alpha = bitmap[offset + 3];
|
||||
if (alpha === 0) return 0;
|
||||
return (0.0722 * bitmap[offset] + 0.7152 * bitmap[offset + 1] + 0.2126 * bitmap[offset + 2]) * (255 / alpha);
|
||||
}
|
||||
|
||||
function bodyLuminance(bitmap: Buffer): number | null {
|
||||
const histogram = new Array<number>(256).fill(0);
|
||||
let opaque = 0;
|
||||
for (let offset = 0; offset < bitmap.length; offset += 4) {
|
||||
if (bitmap[offset + 3] !== 255) continue;
|
||||
histogram[Math.min(255, Math.round(luminance(bitmap, offset)))]++;
|
||||
opaque++;
|
||||
}
|
||||
let seen = 0;
|
||||
for (let level = 0; level < 256; level++) {
|
||||
seen += histogram[level];
|
||||
if (seen * 2 >= opaque && opaque > 0) return level;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function paintMonochromeGlyph(bitmap: Buffer, glyphBgr: Bgr): void {
|
||||
const body = bodyLuminance(bitmap);
|
||||
let highlight = 0;
|
||||
for (let offset = 0; offset < bitmap.length; offset += 4) {
|
||||
if (bitmap[offset + 3] >= 128) {
|
||||
highlight = Math.max(highlight, luminance(bitmap, offset));
|
||||
}
|
||||
}
|
||||
const contrast = body === null ? 0 : highlight - body;
|
||||
for (let offset = 0; offset < bitmap.length; offset += 4) {
|
||||
let alpha = bitmap[offset + 3];
|
||||
if (body !== null && contrast >= MIN_GLYPH_CONTRAST) {
|
||||
const position = (luminance(bitmap, offset) - body) / contrast;
|
||||
const cutout = Math.min(
|
||||
1,
|
||||
Math.max(0, (position - GLYPH_CUTOUT_START) / (GLYPH_CUTOUT_END - GLYPH_CUTOUT_START)),
|
||||
);
|
||||
alpha = Math.round(alpha * (1 - cutout));
|
||||
}
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
bitmap[offset + channel] = Math.round((glyphBgr[channel] * alpha) / 255);
|
||||
}
|
||||
bitmap[offset + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
export function paintTrayIndicatorDot(bitmap: Buffer, width: number, height: number, dotBgr: Bgr): void {
|
||||
const size = Math.min(width, height);
|
||||
const radius = size * DOT_RADIUS_RATIO;
|
||||
const cutRadius = radius + size * DOT_GAP_RATIO;
|
||||
const centerX = width - radius;
|
||||
const centerY = radius;
|
||||
const minX = Math.max(0, Math.floor(centerX - cutRadius));
|
||||
const maxY = Math.min(height, Math.ceil(centerY + cutRadius));
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
for (let x = minX; x < width; x++) {
|
||||
const cut = coverage(x, y, centerX, centerY, cutRadius);
|
||||
if (cut === 0) continue;
|
||||
const dot = coverage(x, y, centerX, centerY, radius);
|
||||
const offset = (y * width + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
bitmap[offset + channel] = Math.round(bitmap[offset + channel] * (1 - cut) + dotBgr[channel] * dot);
|
||||
}
|
||||
bitmap[offset + 3] = Math.round(bitmap[offset + 3] * (1 - cut) + 255 * dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTrayIcon(base: NativeImage, appearance: TrayIconAppearance, indicator: boolean): NativeImage {
|
||||
const monochrome = appearance === 'white' || appearance === 'black';
|
||||
if (!monochrome && !indicator) {
|
||||
return base;
|
||||
}
|
||||
const result = nativeImage.createEmpty();
|
||||
const scaleFactors = base.getScaleFactors();
|
||||
for (const scaleFactor of scaleFactors.length > 0 ? scaleFactors : [1]) {
|
||||
const size = base.getSize(scaleFactor);
|
||||
const width = Math.round(size.width * scaleFactor);
|
||||
const height = Math.round(size.height * scaleFactor);
|
||||
const bitmap = Buffer.from(base.toBitmap({scaleFactor}));
|
||||
if (width === 0 || height === 0 || bitmap.length !== width * height * 4) continue;
|
||||
if (monochrome) {
|
||||
paintMonochromeGlyph(bitmap, GLYPH_BGR[appearance]);
|
||||
}
|
||||
if (indicator) {
|
||||
paintTrayIndicatorDot(bitmap, width, height, appearance === 'template' ? TEMPLATE_DOT_BGR : DOT_BGR);
|
||||
}
|
||||
result.addRepresentation({scaleFactor, width, height, buffer: bitmap});
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
return base;
|
||||
}
|
||||
result.setTemplateImage(appearance === 'template');
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const BLUE_BGRA = [0xd9, 0x41, 0x46, 0xff];
|
||||
export const WHITE_BGRA = [0xff, 0xff, 0xff, 0xff];
|
||||
|
||||
export function pixel(bitmap, width, x, y) {
|
||||
const offset = (y * width + x) * 4;
|
||||
return [...bitmap.subarray(offset, offset + 4)];
|
||||
}
|
||||
|
||||
export function discBitmap(size) {
|
||||
const bitmap = Buffer.alloc(size * size * 4);
|
||||
const center = size / 2;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x + 0.5 - center;
|
||||
const dy = y + 0.5 - center;
|
||||
if (dx * dx + dy * dy > center * center) continue;
|
||||
const color = Math.abs(dy) < size / 16 && Math.abs(dx) < size / 4 ? WHITE_BGRA : BLUE_BGRA;
|
||||
bitmap.set(color, (y * size + x) * 4);
|
||||
}
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
export class FakeNativeImage {
|
||||
constructor() {
|
||||
this.representations = new Map();
|
||||
this.template = false;
|
||||
}
|
||||
|
||||
static fromBitmap(size, scaleFactor = 1) {
|
||||
const image = new FakeNativeImage();
|
||||
const width = size * scaleFactor;
|
||||
image.addRepresentation({scaleFactor, width, height: width, buffer: discBitmap(width)});
|
||||
return image;
|
||||
}
|
||||
|
||||
addRepresentation({scaleFactor = 1, width, height, buffer}) {
|
||||
this.representations.set(scaleFactor, {width, height, buffer: Buffer.from(buffer)});
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.representations.size === 0;
|
||||
}
|
||||
|
||||
getScaleFactors() {
|
||||
return [...this.representations.keys()];
|
||||
}
|
||||
|
||||
getSize(scaleFactor = 1) {
|
||||
const representation = this.representations.get(scaleFactor) ?? [...this.representations.values()][0];
|
||||
if (!representation) return {width: 0, height: 0};
|
||||
return {width: representation.width / scaleFactor, height: representation.height / scaleFactor};
|
||||
}
|
||||
|
||||
toBitmap({scaleFactor = 1} = {}) {
|
||||
return Buffer.from(this.representations.get(scaleFactor).buffer);
|
||||
}
|
||||
|
||||
resize() {
|
||||
return this;
|
||||
}
|
||||
|
||||
setTemplateImage(template) {
|
||||
this.template = template;
|
||||
}
|
||||
|
||||
isTemplateImage() {
|
||||
return this.template;
|
||||
}
|
||||
}
|
||||
|
||||
export const fakeNativeImageModule = {
|
||||
createEmpty: () => new FakeNativeImage(),
|
||||
createFromPath: () => FakeNativeImage.fromBitmap(16),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {describe, test} from 'node:test';
|
||||
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
|
||||
|
||||
installElectronStub();
|
||||
|
||||
const {UNREAD_BADGE_DOT, describeUnreadBadge, parseUnreadBadge} = await import('./UnreadBadge.ts');
|
||||
|
||||
describe('parseUnreadBadge', () => {
|
||||
test('accepts the dot, zero and positive counts', () => {
|
||||
assert.equal(parseUnreadBadge(UNREAD_BADGE_DOT), -1);
|
||||
assert.equal(parseUnreadBadge(0), 0);
|
||||
assert.equal(parseUnreadBadge(7), 7);
|
||||
assert.equal(parseUnreadBadge(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
test('rejects anything that is not a safe integer of at least -1', () => {
|
||||
for (const value of [
|
||||
-2,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
2 ** 53,
|
||||
'3',
|
||||
null,
|
||||
undefined,
|
||||
{count: 1},
|
||||
true,
|
||||
]) {
|
||||
assert.equal(parseUnreadBadge(value), null, String(value));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeUnreadBadge', () => {
|
||||
test('describes the dot and counts and nothing for zero', () => {
|
||||
assert.equal(describeUnreadBadge(UNREAD_BADGE_DOT), 'Unread messages');
|
||||
assert.equal(describeUnreadBadge(1234), 'Unread messages: 1,234');
|
||||
assert.equal(describeUnreadBadge(0), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getNativeLocale, t} from '@electron/main/MainI18n';
|
||||
|
||||
export const UNREAD_BADGE_DOT = -1;
|
||||
|
||||
export function parseUnreadBadge(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < UNREAD_BADGE_DOT) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatBadgeCount(count: number): string {
|
||||
try {
|
||||
return count.toLocaleString(getNativeLocale());
|
||||
} catch {
|
||||
return String(count);
|
||||
}
|
||||
}
|
||||
|
||||
export function describeUnreadBadge(badge: number): string | null {
|
||||
if (badge === UNREAD_BADGE_DOT) {
|
||||
return t('desktop.badge.unreadMessages');
|
||||
}
|
||||
if (badge <= 0) {
|
||||
return null;
|
||||
}
|
||||
return t('desktop.badge.unreadMessagesCount', {count: formatBadgeCount(badge)});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {getNativeLocale, t} from '@electron/main/MainI18n';
|
||||
import {describeUnreadBadge, UNREAD_BADGE_DOT} from '@electron/main/UnreadBadge';
|
||||
import type {BrowserWindow, NativeImage} from 'electron';
|
||||
import {nativeImage} from 'electron';
|
||||
|
||||
@@ -34,35 +34,18 @@ function ensureInitialized(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function formatBadgeCount(count: number): string {
|
||||
try {
|
||||
return count.toLocaleString(getNativeLocale());
|
||||
} catch {
|
||||
return String(count);
|
||||
}
|
||||
}
|
||||
|
||||
function getOverlayIconData(count: number): {
|
||||
index: number | null;
|
||||
description: string;
|
||||
} {
|
||||
if (count === -1) {
|
||||
return {
|
||||
index: 10,
|
||||
description: t('desktop.badge.unreadMessages'),
|
||||
};
|
||||
const description = describeUnreadBadge(count) ?? 'No Notifications';
|
||||
if (count === UNREAD_BADGE_DOT) {
|
||||
return {index: 10, description};
|
||||
}
|
||||
if (count === 0) {
|
||||
return {
|
||||
index: null,
|
||||
description: 'No Notifications',
|
||||
};
|
||||
if (count <= 0) {
|
||||
return {index: null, description};
|
||||
}
|
||||
const index = Math.max(1, Math.min(count, 10)) - 1;
|
||||
return {
|
||||
index,
|
||||
description: t('desktop.badge.unreadMessagesCount', {count: formatBadgeCount(count)}),
|
||||
};
|
||||
return {index: Math.min(count, 10) - 1, description};
|
||||
}
|
||||
|
||||
function applyOverlay(win: BrowserWindow | null, count: number, force: boolean): void {
|
||||
|
||||
@@ -587,6 +587,9 @@ const api: ElectronAPI = {
|
||||
setBadgeCount: (count: number): void => {
|
||||
ipcRenderer.send('set-badge-count', count);
|
||||
},
|
||||
setUnreadBadge: (badge: number): void => {
|
||||
ipcRenderer.send('set-unread-badge', badge);
|
||||
},
|
||||
setNativeLocale: (locale: string, strings: Record<string, string>): void => {
|
||||
ipcRenderer.send('native-locale-set', {locale, strings});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user