Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/images/tray-idle-black.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/tray-idle-black@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 60 additions & 2 deletions src/main/handlers/tray.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { EventEmitter } from 'node:events';

import { app, nativeTheme } from 'electron';
import type { Menubar } from 'electron-menubar';

import { EVENTS } from '../../shared/events';
Expand All @@ -8,15 +11,25 @@ import { registerTrayHandlers } from './tray';
const onMock = vi.fn();

vi.mock('electron', () => ({
app: new EventEmitter(),
nativeTheme: Object.assign(new EventEmitter(), {
shouldUseDarkColorsForSystemIntegratedUI: false,
}),
ipcMain: {
on: (...args: unknown[]) => onMock(...args),
} satisfies Pick<Electron.IpcMain, 'on'>,
}));

vi.mock('../../shared/platform', () => ({ isMacOS: () => false, isWindows: () => true }));

describe('main/handlers/tray.ts', () => {
let menubar: Menubar;

beforeEach(() => {
vi.clearAllMocks();
nativeTheme.removeAllListeners();
app.removeAllListeners();
Object.assign(nativeTheme, { shouldUseDarkColorsForSystemIntegratedUI: false });
menubar = {
tray: {
isDestroyed: vi.fn().mockReturnValue(false),
Expand All @@ -36,7 +49,7 @@ describe('main/handlers/tray.ts', () => {

const registeredEvents = onMock.mock.calls.map((call: unknown[]) => call[0]);

expect(registeredEvents).toContain(EVENTS.USE_ALTERNATE_IDLE_ICON);
expect(registeredEvents).toContain(EVENTS.SET_TRAY_ICON_APPEARANCE);
expect(registeredEvents).toContain(EVENTS.USE_UNREAD_ACTIVE_ICON);
expect(registeredEvents).toContain(EVENTS.UPDATE_ICON_COLOR);
expect(registeredEvents).toContain(EVENTS.UPDATE_ICON_TITLE);
Expand All @@ -63,7 +76,7 @@ describe('main/handlers/tray.ts', () => {
)?.[1];
updateColorHandler?.({}, { notificationsCount: 0, isOnline: true });

expect(menubar.tray.setImage).toHaveBeenCalledWith(TrayIcons.idle);
expect(menubar.tray.setImage).toHaveBeenCalledWith(TrayIcons.dark);
});

it('sets active icon when notifications count is positive', () => {
Expand Down Expand Up @@ -109,4 +122,49 @@ describe('main/handlers/tray.ts', () => {

expect(menubar.tray.setTitle).toHaveBeenCalledWith('5');
});
it('updates an idle icon when the system theme changes and honors a manual override', () => {
registerTrayHandlers(menubar);
const appearance = onMock.mock.calls.find(
(call) => call[0] === EVENTS.SET_TRAY_ICON_APPEARANCE,
)?.[1];
Object.assign(nativeTheme, { shouldUseDarkColorsForSystemIntegratedUI: true });
nativeTheme.emit('updated');
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.light);
appearance({}, 'dark');
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.dark);
nativeTheme.emit('updated');
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.dark);
appearance({}, 'auto');
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.light);
});

it.each([
[{ notificationsCount: 3, isOnline: true }, TrayIcons.active],
[{ notificationsCount: -1, isOnline: true }, TrayIcons.error],
[{ notificationsCount: 0, isOnline: false }, TrayIcons.offline],
])('preserves notification state across theme and preference changes: %j', (state, icon) => {
registerTrayHandlers(menubar);
onMock.mock.calls.find((call) => call[0] === EVENTS.UPDATE_ICON_COLOR)?.[1]({}, state);
nativeTheme.emit('updated');
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(icon);
onMock.mock.calls.find((call) => call[0] === EVENTS.SET_TRAY_ICON_APPEARANCE)?.[1]({}, 'light');
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(icon);
});

it('applies the idle appearance immediately when unread highlighting is disabled', () => {
registerTrayHandlers(menubar);
onMock.mock.calls.find((call) => call[0] === EVENTS.UPDATE_ICON_COLOR)?.[1](
{},
{ notificationsCount: 3, isOnline: true },
);
onMock.mock.calls.find((call) => call[0] === EVENTS.USE_UNREAD_ACTIVE_ICON)?.[1]({}, false);
expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.dark);
});

it('removes the native theme listener on quit', () => {
registerTrayHandlers(menubar);
expect(nativeTheme.listenerCount('updated')).toBe(1);
app.emit('will-quit');
expect(nativeTheme.listenerCount('updated')).toBe(0);
});
});
115 changes: 42 additions & 73 deletions src/main/handlers/tray.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,56 @@
import { app, nativeTheme } from 'electron';
import type { Menubar } from 'electron-menubar';

import { EVENTS, type ITrayColorUpdate } from '../../shared/events';
import {
EVENTS,
isTrayIconAppearance,
type ITrayColorUpdate,
type TrayIconAppearance,
} from '../../shared/events';

import { onMainEvent } from '../events';
import { TrayIcons } from '../icons';
import { getIdleTrayIcon, TrayIcons } from '../icons';

let shouldUseAlternateIdleIcon = false;
let shouldUseUnreadActiveIcon = true;

function setIdleIcon(mb: Menubar): void {
if (shouldUseAlternateIdleIcon) {
mb.tray.setImage(TrayIcons.idleAlternate);
} else {
mb.tray.setImage(TrayIcons.idle);
}
}

function setActiveIcon(mb: Menubar): void {
if (shouldUseUnreadActiveIcon) {
mb.tray.setImage(TrayIcons.active);
} else {
setIdleIcon(mb);
}
}

function setErrorIcon(mb: Menubar): void {
mb.tray.setImage(TrayIcons.error);
}

function setOfflineIcon(mb: Menubar): void {
mb.tray.setImage(TrayIcons.offline);
}

/**
* Register IPC handlers for tray icon visual state.
*
* @param mb - The menubar instance whose tray is controlled.
*/
export function registerTrayHandlers(mb: Menubar): void {
/**
* Toggle the alternate idle tray icon variant.
*/
onMainEvent(EVENTS.USE_ALTERNATE_IDLE_ICON, (_, useAlternateIdleIcon: boolean) => {
shouldUseAlternateIdleIcon = useAlternateIdleIcon;
});
let appearance: TrayIconAppearance = 'auto';
let highlightUnread = true;
let status: ITrayColorUpdate = { notificationsCount: 0, isOnline: true };

/**
* Toggle whether unread notifications show an active (coloured) tray icon.
*/
onMainEvent(EVENTS.USE_UNREAD_ACTIVE_ICON, (_, useUnreadActiveIcon: boolean) => {
shouldUseUnreadActiveIcon = useUnreadActiveIcon;
});

/**
* Update the tray icon based on the current notification count.
*/
onMainEvent(EVENTS.UPDATE_ICON_COLOR, (_, { notificationsCount, isOnline }: ITrayColorUpdate) => {
if (!mb.tray.isDestroyed()) {
if (!isOnline) {
setOfflineIcon(mb);
return;
}

if (notificationsCount < 0) {
setErrorIcon(mb);
return;
}

if (notificationsCount > 0) {
setActiveIcon(mb);
return;
}

setIdleIcon(mb);
const refresh = () => {
if (mb.tray.isDestroyed()) {
return;
}
const { notificationsCount, isOnline } = status;
const icon = !isOnline
? TrayIcons.offline
: notificationsCount < 0
? TrayIcons.error
: notificationsCount > 0 && highlightUnread
? TrayIcons.active
: getIdleTrayIcon(appearance);
mb.tray.setImage(icon);
};

onMainEvent(EVENTS.SET_TRAY_ICON_APPEARANCE, (_, value) => {
if (isTrayIconAppearance(value)) {
appearance = value;
refresh();
}
});

/**
* Update the tray icon title (notification count label on macOS).
*/
onMainEvent(EVENTS.UPDATE_ICON_TITLE, (_, title: string) => {
onMainEvent(EVENTS.USE_UNREAD_ACTIVE_ICON, (_, value) => {
highlightUnread = value;
refresh();
});
onMainEvent(EVENTS.UPDATE_ICON_COLOR, (_, value) => {
status = value;
refresh();
});
onMainEvent(EVENTS.UPDATE_ICON_TITLE, (_, title) => {
if (!mb.tray.isDestroyed()) {
mb.tray.setTitle(title);
}
});

nativeTheme.on('updated', refresh);
app.once('will-quit', () => nativeTheme.removeListener('updated', refresh));
}
51 changes: 43 additions & 8 deletions src/main/icons.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,50 @@
import { TrayIcons } from './icons';
import { nativeTheme } from 'electron';

describe('main/icons.ts', () => {
it('should return icon images', () => {
expect(TrayIcons.active).toContain('assets/images/tray-active.png');
import { isMacOS, isWindows } from '../shared/platform';

expect(TrayIcons.idle).toContain('assets/images/tray-idleTemplate.png');
import { getIdleTrayIcon, TrayIcons } from './icons';

expect(TrayIcons.idleAlternate).toContain('assets/images/tray-idle-white.png');
vi.mock('electron', () => ({
nativeTheme: { shouldUseDarkColorsForSystemIntegratedUI: false, shouldUseDarkColors: false },
}));
vi.mock('../shared/platform', () => ({ isMacOS: vi.fn(), isWindows: vi.fn() }));

expect(TrayIcons.error).toContain('assets/images/tray-error.png');
describe('tray icon appearance', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(false);
vi.mocked(isWindows).mockReturnValue(false);
});

expect(TrayIcons.offline).toContain('assets/images/tray-offline.png');
it('uses a template only for automatic macOS appearance', () => {
vi.mocked(isMacOS).mockReturnValue(true);
expect(getIdleTrayIcon('auto')).toBe(TrayIcons.idle);
expect(getIdleTrayIcon('light')).toBe(TrayIcons.light);
expect(getIdleTrayIcon('dark')).toBe(TrayIcons.dark);
expect(TrayIcons.dark).not.toContain('Template');
});

it.each([true, false])(
'follows Windows taskbar theme, regardless of app dark mode %s',
(appDark) => {
vi.mocked(isWindows).mockReturnValue(true);
Object.assign(nativeTheme, {
shouldUseDarkColors: appDark,
shouldUseDarkColorsForSystemIntegratedUI: true,
});
expect(getIdleTrayIcon('auto')).toBe(TrayIcons.light);
Object.assign(nativeTheme, { shouldUseDarkColorsForSystemIntegratedUI: false });
expect(getIdleTrayIcon('auto')).toBe(TrayIcons.dark);
expect(getIdleTrayIcon('light')).toBe(TrayIcons.light);
},
);

it.each(['GNOME', 'KDE', ''])(
'uses the Linux fallback and respects overrides on %s',
(desktop) => {
vi.stubEnv('XDG_CURRENT_DESKTOP', desktop);
expect(getIdleTrayIcon('auto')).toBe(TrayIcons.light);
expect(getIdleTrayIcon('dark')).toBe(TrayIcons.dark);
vi.unstubAllEnvs();
},
);
});
22 changes: 21 additions & 1 deletion src/main/icons.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import path from 'node:path';

import { nativeTheme } from 'electron';

import type { TrayIconAppearance } from '../shared/events';
import { isMacOS, isWindows } from '../shared/platform';

export const TrayIcons = {
active: getIconPath('tray-active.png'),
idle: getIconPath('tray-idleTemplate.png'),
idleAlternate: getIconPath('tray-idle-white.png'),
light: getIconPath('tray-idle-white.png'),
dark: getIconPath('tray-idle-black.png'),
error: getIconPath('tray-error.png'),
offline: getIconPath('tray-offline.png'),
};

export function getIdleTrayIcon(appearance: TrayIconAppearance): string {
if (appearance !== 'auto') {
return TrayIcons[appearance];
}
if (isMacOS()) {
return TrayIcons.idle;
}
if (isWindows()) {
return nativeTheme.shouldUseDarkColorsForSystemIntegratedUI ? TrayIcons.light : TrayIcons.dark;
}
// Linux does not expose the panel's colour scheme through Electron.
return TrayIcons.light;
}

function getIconPath(iconName: string) {
return path.resolve(__dirname, 'assets', 'images', iconName);
}
4 changes: 2 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
registerTrayHandlers,
registerUpdaterHandlers,
} from './handlers';
import { TrayIcons } from './icons';
import { getIdleTrayIcon } from './icons';
import {
configureWindowEvents,
handleProtocolURL,
Expand All @@ -33,7 +33,7 @@ if (!app.isPackaged) {
}

const mb = menubar({
icon: TrayIcons.idle,
icon: getIdleTrayIcon('auto'),
index: Paths.indexHtml,
browserWindow: WindowConfig,
preloadWindow: true,
Expand Down
8 changes: 4 additions & 4 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { contextBridge, webFrame } from 'electron';

import type { IKeyboardShortcut, NativeThemeSource } from '../shared/events';
import type { IKeyboardShortcut, NativeThemeSource, TrayIconAppearance } from '../shared/events';
import { EVENTS } from '../shared/events';
import { isLinux, isMacOS, isWindows } from '../shared/platform';

Expand Down Expand Up @@ -126,11 +126,11 @@ export const api = {
updateTitle: (title = '') => sendMainEvent(EVENTS.UPDATE_ICON_TITLE, title),

/**
* Switch the tray icon to an alternate idle icon variant.
* Set the idle tray icon appearance independently of the app theme.
*
* @param value - `true` to use the alternate idle icon, `false` for the default.
*/
useAlternateIdleIcon: (value: boolean) => sendMainEvent(EVENTS.USE_ALTERNATE_IDLE_ICON, value),
setAppearance: (value: TrayIconAppearance) =>
sendMainEvent(EVENTS.SET_TRAY_ICON_APPEARANCE, value),

/**
* Switch the tray icon to an "active" variant when there are unread notifications.
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/__helpers__/visual.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
tray: {
updateColor: vi.fn(),
updateTitle: vi.fn(),
useAlternateIdleIcon: vi.fn(),
setAppearance: vi.fn(),
useUnreadActiveIcon: vi.fn(),
},
notificationSoundPath: vi.fn(),
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
tray: {
updateColor: vi.fn(),
updateTitle: vi.fn(),
useAlternateIdleIcon: vi.fn(),
setAppearance: vi.fn(),
useUnreadActiveIcon: vi.fn(),
},
notificationSoundPath: vi.fn(),
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const mockNotificationSettings: NotificationSettingsState = {
const mockTraySettings: TraySettingsState = {
showNotificationsCountInTray: true,
useUnreadActiveIcon: true,
useAlternateIdleIcon: false,
trayIconAppearance: 'auto',
};

const mockSystemSettings: SystemSettingsState = {
Expand Down
Loading