Repository: gitify-app/gitify (ts)

Issue:
GUI scale setting
### 📝 Provide a description of the new feature

Recent discussions in #1276 made me think of a feature idea: GUI scale.

I'm already using `Cmd +` and `Cmd -` on my Mac to control the size of Gitify's window (which I'm unzooming a lot so I get more space/informations -- but this kind of is a poweruser feature as it's a feature implemented into electron and not Gitify itself, so basic users might not know about it.

So why not add some kind of a setting to control this from Gitify's settings page?

### ➕ Additional Information

_No response_

Pull request description:
feat: zoom buttons
This is the second iteration of the zoom feature after #1318.

Closes #1312.

| Dark | Light |
| ----- | ----- |
| <img width="612" alt="image" src="https://github.com/gitify-app/gitify/assets/19473034/ec3e6b5f-f19d-4da8-b999-52ac583cc1dc"> | <img width="612" alt="image" src="https://github.com/gitify-app/gitify/assets/19473034/bdc5340f-90dd-4fc1-aa13-1f3ecb2dc689"> | 

Had to add a timeout to the listening to the `resize` event because it was very trigger-happy. Found a fix by doing debounced update calls. More info [here](https://bencentra.com/code/2015/02/27/optimizing-window-resize.html) 

@adufr @setchy Let me know your thoughts.



Patch:
diff --git a/package.json b/package.json
index d0b5cf7d..9e186355 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 662cdb33..dc76d29a 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 3362dc13..c2d0e68c 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 5226657c..9885f0b1 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 fa3c6bd9..c51696b1 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.tsx b/src/components/settings/AppearanceSettings.tsx
index ae0b959b..f0de6d02 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 (
     <fieldset>
       <Legend icon={PaintbrushIcon}>Appearance</Legend>
@@ -44,6 +63,48 @@ export const AppearanceSettings: FC = () => {
         }}
         className="mb-0"
       />
+      <div className="flex">
+        <label
+          htmlFor="Zoom"
+          className="mr-3 content-center font-medium text-sm text-gray-700 dark:text-gray-200"
+        >
+          Zoom:
+        </label>
+        <Button
+          label="Zoom Out"
+          onClick={() =>
+            zoomPercentage > 0 &&
+            webFrame.setZoomLevel(zoomPercentageToLevel(zoomPercentage - 10))
+          }
+          className="rounded-r-none"
+          size="xs"
+        >
+          -
+        </Button>
+        <span className="flex w-16 items-center justify-center rounded-none border border-gray-300 bg-transparent text-sm text-gray-700 dark:text-gray-200">
+          {zoomPercentage.toFixed(0)}%
+        </span>
+        <Button
+          label="Zoom In"
+          onClick={() =>
+            zoomPercentage < 120 &&
+            webFrame.setZoomLevel(zoomPercentageToLevel(zoomPercentage + 10))
+          }
+          className="rounded-none"
+          size="xs"
+        >
+          +
+        </Button>
+        <Button
+          label="Reset Zoom"
+          onClick={() => webFrame.setZoomLevel(0)}
+          variant="destructive"
+          className="rounded-l-none"
+          size="xs"
+        >
+          X
+        </Button>
+      </div>
       <Checkbox
         name="detailedNotifications"
         label="Detailed notifications"
diff --git a/src/context/App.tsx b/src/context/App.tsx
index 36ea4742..adbcf1e5 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 0d04fcf2..8a290881 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/types.ts b/src/types.ts
index f60c03f7..fd6f0822 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.ts b/src/utils/zoom.ts
new file mode 100644
index 00000000..17bdbf8e
--- /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;
+};


Test patch:
diff --git a/src/components/settings/AppearanceSettings.test.tsx b/src/components/settings/AppearanceSettings.test.tsx
index d7ea2138..843b39d0 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(
+        <AppContext.Provider
+          value={{
+            auth: mockAuth,
+            settings: mockSettings,
+            updateSetting,
+          }}
+        >
+          <MemoryRouter>
+            <AppearanceSettings />
+          </MemoryRouter>
+        </AppContext.Provider>,
+      );
+    });
+
+    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(
+        <AppContext.Provider
+          value={{
+            auth: mockAuth,
+            settings: mockSettings,
+            updateSetting,
+          }}
+        >
+          <MemoryRouter>
+            <AppearanceSettings />
+          </MemoryRouter>
+        </AppContext.Provider>,
+      );
+    });
+
+    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/SettingsFooter.test.tsx b/src/components/settings/SettingsFooter.test.tsx
index c4ea9aca..ddd6bf31 100644
--- a/src/components/settings/SettingsFooter.test.tsx
+++ b/src/components/settings/SettingsFooter.test.tsx
@@ -11,6 +11,8 @@ jest.mock('react-router-dom', () => ({
   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 121fdc68..0abc4f27 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/routes/Settings.test.tsx b/src/routes/Settings.test.tsx
index 6b645ae8..23aaf202 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 9f2426b8..c04f6dbc 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`] = `
           </div>
         </div>
       </div>
+      <div
+        class="flex"
+      >
+        <label
+          class="mr-3 content-center font-medium text-sm text-gray-700 dark:text-gray-200"
+          for="Zoom"
+        >
+          Zoom:
+        </label>
+        <button
+          aria-label="Zoom Out"
+          class="ring-offset-background focus-visible:ring-ring inline-flex w-fit items-center justify-center whitespace-nowrap text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-gray-300 dark:text-black hover:opacity-90 h-7 rounded-md px-2 py-1 rounded-r-none"
+        >
+          -
+        </button>
+        <span
+          class="flex w-16 items-center justify-center rounded-none border border-gray-300 bg-transparent text-sm text-gray-700 dark:text-gray-200"
+        >
+          100
+          %
+        </span>
+        <button
+          aria-label="Zoom In"
+          class="ring-offset-background focus-visible:ring-ring inline-flex w-fit items-center justify-center whitespace-nowrap text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-gray-300 dark:text-black hover:opacity-90 h-7 px-2 py-1 rounded-none"
+        >
+          +
+        </button>
+        <button
+          aria-label="Reset Zoom"
+          class="ring-offset-background focus-visible:ring-ring inline-flex w-fit items-center justify-center whitespace-nowrap text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-red-600 text-white hover:opacity-90 h-7 rounded-md px-2 py-1 rounded-l-none"
+        >
+          X
+        </button>
+      </div>
       <div
         class="mb-3 mt-1 text-sm"
       >
diff --git a/src/utils/zoom.test.ts b/src/utils/zoom.test.ts
new file mode 100644
index 00000000..84220c2a
--- /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);
+  });
+});