diff --git a/package.json b/package.json index d0b5cf7da..9e1863559 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "nock": "13.5.4", "postcss": "8.4.39", "postcss-loader": "8.1.1", + "resize-observer-polyfill": "1.5.1", "rimraf": "5.0.8", "style-loader": "4.0.0", "tailwindcss": "3.4.4", @@ -150,6 +151,6 @@ "packageManager": "pnpm@9.5.0", "lint-staged": { "*.{js,json,ts,tsx}": "biome format --fix", - "*.{js,ts,tsx}": "pnpm test -- --onlyChanged -u --passWithNoTests" + "*.{js,ts,tsx}": "pnpm test -- --findRelatedTests -u --passWithNoTests" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 662cdb330..dc76d29a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: postcss-loader: specifier: 8.1.1 version: 8.1.1(postcss@8.4.39)(typescript@5.5.3)(webpack@5.92.1(webpack-cli@5.1.4)) + resize-observer-polyfill: + specifier: 1.5.1 + version: 1.5.1 rimraf: specifier: 5.0.8 version: 5.0.8 @@ -2595,6 +2598,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -6072,6 +6078,8 @@ snapshots: requires-port@1.0.0: {} + resize-observer-polyfill@1.5.1: {} + resolve-alpn@1.2.1: {} resolve-cwd@3.0.0: diff --git a/src/__mocks__/electron.js b/src/__mocks__/electron.js index 3362dc13e..c2d0e68c2 100644 --- a/src/__mocks__/electron.js +++ b/src/__mocks__/electron.js @@ -49,4 +49,8 @@ module.exports = { shell: { openExternal: jest.fn(), }, + webFrame: { + setZoomLevel: jest.fn(), + getZoomLevel: jest.fn(), + }, }; diff --git a/src/__mocks__/state-mocks.ts b/src/__mocks__/state-mocks.ts index 5226657c3..9885f0b10 100644 --- a/src/__mocks__/state-mocks.ts +++ b/src/__mocks__/state-mocks.ts @@ -79,6 +79,7 @@ export const mockSettings: SettingsState = { showNotificationsCountInTray: false, openAtStartup: false, theme: Theme.SYSTEM, + zoomPercentage: 100, detailedNotifications: true, markAsDoneOnOpen: false, showAccountHostname: false, diff --git a/src/components/buttons/Button.tsx b/src/components/buttons/Button.tsx index fa3c6bd98..c51696b10 100644 --- a/src/components/buttons/Button.tsx +++ b/src/components/buttons/Button.tsx @@ -19,7 +19,7 @@ const buttonVariants = cva( }, size: { default: 'min-w-20 h-10 px-4 py-1', - xs: 'h-6 rounded-md px-2', + xs: 'h-7 rounded-md px-2 py-1', sm: 'h-9 rounded-md px-2 py-1', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', diff --git a/src/components/settings/AppearanceSettings.test.tsx b/src/components/settings/AppearanceSettings.test.tsx index d7ea21381..843b39d05 100644 --- a/src/components/settings/AppearanceSettings.test.tsx +++ b/src/components/settings/AppearanceSettings.test.tsx @@ -1,11 +1,15 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; +import { webFrame } from 'electron'; import { MemoryRouter } from 'react-router-dom'; import { mockAuth, mockSettings } from '../../__mocks__/state-mocks'; import { AppContext } from '../../context/App'; import { AppearanceSettings } from './AppearanceSettings'; +global.ResizeObserver = require('resize-observer-polyfill'); + describe('routes/components/settings/AppearanceSettings.tsx', () => { const updateSetting = jest.fn(); + const zoomTimeout = () => new Promise((r) => setTimeout(r, 300)); afterEach(() => { jest.clearAllMocks(); @@ -34,6 +38,88 @@ describe('routes/components/settings/AppearanceSettings.tsx', () => { expect(updateSetting).toHaveBeenCalledWith('theme', 'LIGHT'); }); + it('should update the zoom value when using CMD + and CMD -', async () => { + webFrame.getZoomLevel = jest.fn().mockReturnValue(-1); + + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent(window, new Event('resize')); + await zoomTimeout(); + + expect(updateSetting).toHaveBeenCalledTimes(1); + expect(updateSetting).toHaveBeenCalledWith('zoomPercentage', 50); + }); + + it('should update the zoom values when using the zoom buttons', async () => { + webFrame.getZoomLevel = jest.fn().mockReturnValue(0); + webFrame.setZoomLevel = jest.fn().mockImplementation((level) => { + webFrame.getZoomLevel = jest.fn().mockReturnValue(level); + fireEvent(window, new Event('resize')); + }); + + await act(async () => { + render( + + + + + , + ); + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Zoom Out')); + await zoomTimeout(); + }); + + expect(updateSetting).toHaveBeenCalledTimes(1); + expect(updateSetting).toHaveBeenCalledWith('zoomPercentage', 90); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Zoom Out')); + await zoomTimeout(); + + expect(updateSetting).toHaveBeenCalledTimes(2); + expect(updateSetting).toHaveBeenNthCalledWith(2, 'zoomPercentage', 80); + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Zoom In')); + await zoomTimeout(); + + expect(updateSetting).toHaveBeenCalledTimes(3); + expect(updateSetting).toHaveBeenNthCalledWith(3, 'zoomPercentage', 90); + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Reset Zoom')); + await zoomTimeout(); + + expect(updateSetting).toHaveBeenCalledTimes(4); + expect(updateSetting).toHaveBeenNthCalledWith(4, 'zoomPercentage', 100); + }); + }); + it('should toggle detailed notifications checkbox', async () => { await act(async () => { render( diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index ae0b959b7..f0de6d028 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -7,17 +7,25 @@ import { PaintbrushIcon, TagIcon, } from '@primer/octicons-react'; -import { ipcRenderer } from 'electron'; -import { type FC, useContext, useEffect } from 'react'; +import { ipcRenderer, webFrame } from 'electron'; +import { type FC, useContext, useEffect, useState } from 'react'; import { AppContext } from '../../context/App'; import { Size, Theme } from '../../types'; import { setTheme } from '../../utils/theme'; +import { zoomLevelToPercentage, zoomPercentageToLevel } from '../../utils/zoom'; +import { Button } from '../buttons/Button'; import { Checkbox } from '../fields/Checkbox'; import { RadioGroup } from '../fields/RadioGroup'; import { Legend } from './Legend'; +let timeout: NodeJS.Timeout; +const DELAY = 200; + export const AppearanceSettings: FC = () => { const { settings, updateSetting } = useContext(AppContext); + const [zoomPercentage, setZoomPercentage] = useState( + zoomLevelToPercentage(webFrame.getZoomLevel()), + ); useEffect(() => { ipcRenderer.on('gitify:update-theme', (_, updatedTheme: Theme) => { @@ -27,6 +35,17 @@ export const AppearanceSettings: FC = () => { }); }, [settings.theme]); + window.addEventListener('resize', () => { + // clear the timeout + clearTimeout(timeout); + // start timing for event "completion" + timeout = setTimeout(() => { + const zoomPercentage = zoomLevelToPercentage(webFrame.getZoomLevel()); + setZoomPercentage(zoomPercentage); + updateSetting('zoomPercentage', zoomPercentage); + }, DELAY); + }); + return (
Appearance @@ -44,6 +63,48 @@ export const AppearanceSettings: FC = () => { }} className="mb-0" /> +
+ + + + {zoomPercentage.toFixed(0)}% + + + +
({ useNavigate: () => mockNavigate, })); +global.ResizeObserver = require('resize-observer-polyfill'); + describe('routes/components/settings/SettingsFooter.tsx', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/src/context/App.test.tsx b/src/context/App.test.tsx index 121fdc68e..0abc4f277 100644 --- a/src/context/App.test.tsx +++ b/src/context/App.test.tsx @@ -383,6 +383,7 @@ describe('context/App.tsx', () => { keyboardShortcut: true, groupBy: 'REPOSITORY', filterReasons: [], + zoomPercentage: 100, } as SettingsState, }); }); @@ -438,6 +439,7 @@ describe('context/App.tsx', () => { keyboardShortcut: true, groupBy: 'REPOSITORY', filterReasons: [], + zoomPercentage: 100, } as SettingsState, }); }); diff --git a/src/context/App.tsx b/src/context/App.tsx index 36ea47424..adbcf1e5b 100644 --- a/src/context/App.tsx +++ b/src/context/App.tsx @@ -1,3 +1,4 @@ +import { webFrame } from 'electron'; import { type ReactNode, createContext, @@ -42,6 +43,7 @@ import Constants from '../utils/constants'; import { getNotificationCount } from '../utils/notifications'; import { clearState, loadState, saveState } from '../utils/storage'; import { setTheme } from '../utils/theme'; +import { zoomPercentageToLevel } from '../utils/zoom'; const defaultAuth: AuthState = { accounts: [], @@ -62,6 +64,7 @@ export const defaultSettings: SettingsState = { showNotificationsCountInTray: false, openAtStartup: false, theme: Theme.SYSTEM, + zoomPercentage: 100, detailedNotifications: true, markAsDoneOnOpen: false, showAccountHostname: false, @@ -244,6 +247,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { if (existing.settings) { setKeyboardShortcut(existing.settings.keyboardShortcut); setSettings({ ...defaultSettings, ...existing.settings }); + webFrame.setZoomLevel( + zoomPercentageToLevel(existing.settings.zoomPercentage), + ); return existing.settings; } }, []); diff --git a/src/electron/main.js b/src/electron/main.js index 0d04fcf23..8a290881c 100644 --- a/src/electron/main.js +++ b/src/electron/main.js @@ -71,9 +71,6 @@ app.whenReady().then(async () => { mb.tray.popUpContextMenu(contextMenu, { x: bounds.x, y: bounds.y }); }); - // Force the window to retrieve its previous zoom factor - mb.window.webContents.setZoomFactor(mb.window.webContents.getZoomFactor()); - // Custom key events mb.window.webContents.on('before-input-event', (event, input) => { if (input.key === 'Escape') { diff --git a/src/routes/Settings.test.tsx b/src/routes/Settings.test.tsx index 6b645ae85..23aaf2022 100644 --- a/src/routes/Settings.test.tsx +++ b/src/routes/Settings.test.tsx @@ -5,6 +5,8 @@ import { mockPlatform } from '../__mocks__/utils'; import { AppContext } from '../context/App'; import { SettingsRoute } from './Settings'; +global.ResizeObserver = require('resize-observer-polyfill'); + const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), diff --git a/src/routes/__snapshots__/Settings.test.tsx.snap b/src/routes/__snapshots__/Settings.test.tsx.snap index 9f2426b86..c04f6dbc1 100644 --- a/src/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/routes/__snapshots__/Settings.test.tsx.snap @@ -154,6 +154,40 @@ exports[`routes/Settings.tsx should render itself & its children 1`] = ` +
+ + + + 100 + % + + + +
diff --git a/src/types.ts b/src/types.ts index f60c03f7d..fd6f0822b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,7 +51,7 @@ export interface Account { user: GitifyUser | null; } -export type SettingsValue = boolean | Theme | GroupBy | Reason[]; +export type SettingsValue = boolean | Theme | GroupBy | Reason[] | number; export type SettingsState = AppearanceSettingsState & NotificationSettingsState & @@ -60,6 +60,7 @@ export type SettingsState = AppearanceSettingsState & interface AppearanceSettingsState { theme: Theme; + zoomPercentage: number; detailedNotifications: boolean; showAccountHostname: boolean; showPills: boolean; diff --git a/src/utils/zoom.test.ts b/src/utils/zoom.test.ts new file mode 100644 index 000000000..84220c2aa --- /dev/null +++ b/src/utils/zoom.test.ts @@ -0,0 +1,21 @@ +import { zoomLevelToPercentage, zoomPercentageToLevel } from './zoom'; + +describe('utils/zoom.ts', () => { + it('should convert percentage to zoom level', () => { + expect(zoomPercentageToLevel(100)).toBe(0); + expect(zoomPercentageToLevel(50)).toBe(-1); + expect(zoomPercentageToLevel(0)).toBe(-2); + expect(zoomPercentageToLevel(150)).toBe(1); + + expect(zoomPercentageToLevel(undefined)).toBe(0); + }); + + it('should convert zoom level to percentage', () => { + expect(zoomLevelToPercentage(0)).toBe(100); + expect(zoomLevelToPercentage(-1)).toBe(50); + expect(zoomLevelToPercentage(-2)).toBe(0); + expect(zoomLevelToPercentage(1)).toBe(150); + + expect(zoomLevelToPercentage(undefined)).toBe(100); + }); +}); diff --git a/src/utils/zoom.ts b/src/utils/zoom.ts new file mode 100644 index 000000000..17bdbf8ec --- /dev/null +++ b/src/utils/zoom.ts @@ -0,0 +1,22 @@ +const RECOMMENDED = 100; +const MULTIPLIER = 2; + +/** + * Zoom percentage to level. 100% is the recommended zoom level (0). If somehow the percentage is not set, it will return 0, the default zoom level. + * @param percentage 0-150 + * @returns zoomLevel -2 to 0.5 + */ +export const zoomPercentageToLevel = (percentage: number): number => { + if (typeof percentage === 'undefined') return 0; + return ((percentage - RECOMMENDED) * MULTIPLIER) / 100; +}; + +/** + * Zoom level to percentage. 0 is the recommended zoom level (100%). If somehow the zoom level is not set, it will return 100, the default zoom percentage. + * @param zoom -2 to 0.5 + * @returns percentage 0-150 + */ +export const zoomLevelToPercentage = (zoom: number): number => { + if (typeof zoom === 'undefined') return 100; + return (zoom / MULTIPLIER) * 100 + RECOMMENDED; +};