From 97a87de41821426c9078a6113b09825675268f3d Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 18 Nov 2025 07:26:02 -0500 Subject: [PATCH 1/6] refactor: state restore Signed-off-by: Adam Setch --- src/renderer/context/App.tsx | 62 +++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 190f89e9f..eb2fb382f 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -106,6 +106,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { const { setColorMode, setDayScheme, setNightScheme } = useTheme(); const [auth, setAuth] = useState(defaultAuth); const [settings, setSettings] = useState(defaultSettings); + const [needsAccountRefresh, setNeedsAccountRefresh] = useState(false); + const { removeAccountNotifications, fetchNotifications, @@ -134,6 +136,34 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { restoreSettings(); }, []); + // Refresh account details on startup or restore + useEffect(() => { + if (!needsAccountRefresh || auth.accounts.length === 0) { + return; + } + + (async () => { + for (const account of auth.accounts) { + await refreshAccount(account); + } + + setNeedsAccountRefresh(false); + })(); + }, [needsAccountRefresh, auth.accounts]); + + useIntervalTimer(() => { + for (const account of auth.accounts) { + refreshAccount(account); + } + }, Constants.REFRESH_ACCOUNTS_INTERVAL_MS); + + // Apply zoom level when settings change + useEffect(() => { + globalThis.gitify.zoom.setLevel( + zoomPercentageToLevel(settings.zoomPercentage), + ); + }, [settings.zoomPercentage]); + useEffect(() => { const colorMode = mapThemeModeToColorMode(settings.theme); const colorScheme = mapThemeModeToColorScheme( @@ -179,12 +209,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { settings.fetchType === FetchType.INACTIVITY ? settings.fetchInterval : null, ); - useIntervalTimer(() => { - for (const account of auth.accounts) { - refreshAccount(account); - } - }, Constants.REFRESH_ACCOUNTS_INTERVAL_MS); - // biome-ignore lint/correctness/useExhaustiveDependencies: We want to update the tray on setting or notification changes useEffect(() => { setUseUnreadActiveIcon(settings.useUnreadActiveIcon); @@ -208,7 +232,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [settings.openAtStartup]); useEffect(() => { - window.gitify.onResetApp(() => { + globalThis.gitify.onResetApp(() => { clearState(); setAuth(defaultAuth); setSettings(defaultSettings); @@ -309,33 +333,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { // Restore settings before accounts to ensure filters are available before fetching notifications if (existing.settings) { - setUseUnreadActiveIcon(existing.settings.useUnreadActiveIcon); - setUseAlternateIdleIcon(existing.settings.useAlternateIdleIcon); - setKeyboardShortcut(existing.settings.keyboardShortcut); setSettings({ ...defaultSettings, ...existing.settings }); - window.gitify.zoom.setLevel( - zoomPercentageToLevel(existing.settings.zoomPercentage), - ); } if (existing.auth) { setAuth({ ...defaultAuth, ...existing.auth }); - - // Refresh account data on app start - for (const account of existing.auth.accounts) { - /** - * Check if the account is using an encrypted token. - * If not encrypt it and save it. - */ - try { - await decryptValue(account.token); - } catch (_err) { - const encryptedToken = await encryptValue(account.token); - account.token = encryptedToken as Token; - } - - await refreshAccount(account); - } + // Trigger the effect to refresh accounts and handle token encryption + setNeedsAccountRefresh(true); } }, []); From 5b51c39e23d78b8d5e0ceefc086b8832de870659 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 18 Nov 2025 07:31:14 -0500 Subject: [PATCH 2/6] refactor: state restore Signed-off-by: Adam Setch --- src/renderer/context/App.tsx | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index eb2fb382f..b81f7b8bf 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -41,7 +41,6 @@ import { removeAccount, } from '../utils/auth/utils'; import { - decryptValue, encryptValue, setAutoLaunch, setKeyboardShortcut, @@ -131,10 +130,25 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [unreadNotificationCount], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: restoreSettings is stable and should run only once + const restoreSettings = useCallback(async () => { + const existing = loadState(); + + // Restore settings before accounts to ensure filters are available before fetching notifications + if (existing.settings) { + setSettings({ ...defaultSettings, ...existing.settings }); + } + + if (existing.auth) { + setAuth({ ...defaultAuth, ...existing.auth }); + + // Trigger the effect to refresh accounts and handle token encryption + setNeedsAccountRefresh(true); + } + }, []); + useEffect(() => { restoreSettings(); - }, []); + }, [restoreSettings]); // Refresh account details on startup or restore useEffect(() => { @@ -151,6 +165,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { })(); }, [needsAccountRefresh, auth.accounts]); + // Refresh account details on interval useIntervalTimer(() => { for (const account of auth.accounts) { refreshAccount(account); @@ -328,21 +343,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [auth, settings, removeAccountNotifications], ); - const restoreSettings = useCallback(async () => { - const existing = loadState(); - - // Restore settings before accounts to ensure filters are available before fetching notifications - if (existing.settings) { - setSettings({ ...defaultSettings, ...existing.settings }); - } - - if (existing.auth) { - setAuth({ ...defaultAuth, ...existing.auth }); - // Trigger the effect to refresh accounts and handle token encryption - setNeedsAccountRefresh(true); - } - }, []); - const fetchNotificationsWithAccounts = useCallback( async () => await fetchNotifications({ auth, settings }), [auth, settings, fetchNotifications], From 1d38636744238e39e981f1ee33ffa1f10d1d8dca Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 18 Nov 2025 13:09:12 -0500 Subject: [PATCH 3/6] refactor: move resize listener to global Signed-off-by: Adam Setch --- .../settings/AppearanceSettings.test.tsx | 82 ++++--------------- .../settings/AppearanceSettings.tsx | 25 +----- src/renderer/context/App.test.tsx | 20 +++++ src/renderer/context/App.tsx | 40 +++++++-- src/renderer/utils/zoom.test.ts | 43 ++++++++++ src/renderer/utils/zoom.ts | 9 ++ 6 files changed, 123 insertions(+), 96 deletions(-) diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index 77dd401a9..dc05973f9 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -1,13 +1,13 @@ -import { act, fireEvent, screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithAppContext } from '../../__helpers__/test-utils'; import { mockGitHubAppAccount } from '../../__mocks__/account-mocks'; +import * as zoom from '../../utils/zoom'; import { AppearanceSettings } from './AppearanceSettings'; describe('renderer/components/settings/AppearanceSettings.tsx', () => { const updateSettingMock = jest.fn(); - const zoomTimeout = () => new Promise((r) => setTimeout(r, 300)); afterEach(() => { jest.clearAllMocks(); @@ -45,28 +45,12 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(updateSettingMock).toHaveBeenCalledWith('increaseContrast', true); }); - it('should update the zoom value when using CMD + and CMD -', async () => { - window.gitify.zoom.getLevel = jest.fn().mockReturnValue(-1); - - await act(async () => { - renderWithAppContext(, { - updateSetting: updateSettingMock, - }); - }); - - fireEvent(window, new Event('resize')); - await zoomTimeout(); - - expect(updateSettingMock).toHaveBeenCalledTimes(1); - expect(updateSettingMock).toHaveBeenCalledWith('zoomPercentage', 50); - }); - it('should update the zoom values when using the zoom buttons', async () => { - window.gitify.zoom.getLevel = jest.fn().mockReturnValue(0); - window.gitify.zoom.setLevel = jest.fn().mockImplementation((level) => { - window.gitify.zoom.getLevel = jest.fn().mockReturnValue(level); - fireEvent(window, new Event('resize')); - }); + const zoomOutSpy = jest.spyOn(zoom, 'decreaseZoom').mockImplementation(); + const zoomInSpy = jest.spyOn(zoom, 'increaseZoom').mockImplementation(); + const zoomResetSpy = jest + .spyOn(zoom, 'resetZoomLevel') + .mockImplementation(); await act(async () => { renderWithAppContext(, { @@ -75,55 +59,19 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { }); // Zoom Out - await act(async () => { - await userEvent.click(screen.getByTestId('settings-zoom-out')); - await zoomTimeout(); - - expect(updateSettingMock).toHaveBeenCalledTimes(1); - expect(updateSettingMock).toHaveBeenNthCalledWith( - 1, - 'zoomPercentage', - 90, - ); - }); + await userEvent.click(screen.getByTestId('settings-zoom-out')); + expect(zoomOutSpy).toHaveBeenCalledTimes(1); - await act(async () => { - await userEvent.click(screen.getByTestId('settings-zoom-out')); - await zoomTimeout(); - - expect(updateSettingMock).toHaveBeenCalledTimes(2); - expect(updateSettingMock).toHaveBeenNthCalledWith( - 2, - 'zoomPercentage', - 80, - ); - }); + await userEvent.click(screen.getByTestId('settings-zoom-out')); + expect(zoomOutSpy).toHaveBeenCalledTimes(2); // Zoom In - await act(async () => { - await userEvent.click(screen.getByTestId('settings-zoom-in')); - await zoomTimeout(); - - expect(updateSettingMock).toHaveBeenCalledTimes(3); - expect(updateSettingMock).toHaveBeenNthCalledWith( - 3, - 'zoomPercentage', - 90, - ); - }); + await userEvent.click(screen.getByTestId('settings-zoom-in')); + expect(zoomInSpy).toHaveBeenCalledTimes(1); // Zoom Reset - await act(async () => { - await userEvent.click(screen.getByTestId('settings-zoom-reset')); - await zoomTimeout(); - - expect(updateSettingMock).toHaveBeenCalledTimes(4); - expect(updateSettingMock).toHaveBeenNthCalledWith( - 4, - 'zoomPercentage', - 100, - ); - }); + await userEvent.click(screen.getByTestId('settings-zoom-reset')); + expect(zoomResetSpy).toHaveBeenCalledTimes(1); }); it('should toggle account header checkbox', async () => { diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 6cb966974..393e8d83d 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -1,4 +1,4 @@ -import { type FC, useContext, useState } from 'react'; +import { type FC, useContext } from 'react'; import { PaintbrushIcon, @@ -23,33 +23,16 @@ import { canIncreaseZoom, decreaseZoom, increaseZoom, + resetZoomLevel, zoomLevelToPercentage, } from '../../utils/zoom'; import { Checkbox } from '../fields/Checkbox'; import { FieldLabel } from '../fields/FieldLabel'; import { Title } from '../primitives/Title'; -let timeout: NodeJS.Timeout; -const DELAY = 200; - export const AppearanceSettings: FC = () => { const { auth, settings, updateSetting } = useContext(AppContext); - const [zoomPercentage, setZoomPercentage] = useState( - zoomLevelToPercentage(window.gitify.zoom.getLevel()), - ); - - window.addEventListener('resize', () => { - // clear the timeout - clearTimeout(timeout); - // start timing for event "completion" - timeout = setTimeout(() => { - const zoomPercentage = zoomLevelToPercentage( - window.gitify.zoom.getLevel(), - ); - setZoomPercentage(zoomPercentage); - updateSetting('zoomPercentage', zoomPercentage); - }, DELAY); - }); + const zoomPercentage = zoomLevelToPercentage(window.gitify.zoom.getLevel()); return (
@@ -148,7 +131,7 @@ export const AppearanceSettings: FC = () => { aria-label="Reset zoom" data-testid="settings-zoom-reset" icon={SyncIcon} - onClick={() => window.gitify.zoom.setLevel(0)} + onClick={() => resetZoomLevel()} size="small" unsafeDisableTooltip={true} variant="danger" diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index ea88529d7..b4a7a46a9 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -55,6 +55,8 @@ describe('renderer/context/App.tsx', () => { const markNotificationsAsDoneMock = jest.fn(); const unsubscribeNotificationMock = jest.fn(); + const zoomTimeout = () => new Promise((r) => setTimeout(r, 300)); + const saveStateSpy = jest .spyOn(storage, 'saveState') .mockImplementation(jest.fn()); @@ -327,4 +329,22 @@ describe('renderer/context/App.tsx', () => { }); }); }); + + describe('zoom listeners', () => { + const updateSettingMock = jest.fn(); + + it('should update the zoom value when using CMD + and CMD -', async () => { + window.gitify.zoom.getLevel = jest.fn().mockReturnValue(-1); + + renderWithAppContext({null}, { + updateSetting: updateSettingMock, + }); + + fireEvent(window, new Event('resize')); + await zoomTimeout(); + + expect(updateSettingMock).toHaveBeenCalledTimes(1); + expect(updateSettingMock).toHaveBeenCalledWith('zoomPercentage', 50); + }); + }); }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index b81f7b8bf..bcac45f4e 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -59,7 +59,7 @@ import { mapThemeModeToColorScheme, } from '../utils/theme'; import { setTrayIconColorAndTitle } from '../utils/tray'; -import { zoomPercentageToLevel } from '../utils/zoom'; +import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/zoom'; import { defaultAuth, defaultFilters, defaultSettings } from './defaults'; export interface AppContextState { @@ -172,13 +172,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { } }, Constants.REFRESH_ACCOUNTS_INTERVAL_MS); - // Apply zoom level when settings change - useEffect(() => { - globalThis.gitify.zoom.setLevel( - zoomPercentageToLevel(settings.zoomPercentage), - ); - }, [settings.zoomPercentage]); - useEffect(() => { const colorMode = mapThemeModeToColorMode(settings.theme); const colorScheme = mapThemeModeToColorScheme( @@ -285,6 +278,37 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [updateSetting, settings], ); + // Global window resize listener to sync zoom percentage + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to update on settings.zoomPercentage changes + useEffect(() => { + // Set the zoom level when settings.zoomPercentage changes + globalThis.gitify.zoom.setLevel( + zoomPercentageToLevel(settings.zoomPercentage), + ); + + // Sync zoom percentage in settings when window is resized + let timeout: NodeJS.Timeout; + const DELAY = 200; + + const handleResize = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + const zoomPercentage = zoomLevelToPercentage( + globalThis.gitify.zoom.getLevel(), + ); + + updateSetting('zoomPercentage', zoomPercentage); + }, DELAY); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + clearTimeout(timeout); + }; + }, [settings.zoomPercentage]); + const isLoggedIn = useMemo(() => { return hasAccounts(auth); }, [auth]); diff --git a/src/renderer/utils/zoom.test.ts b/src/renderer/utils/zoom.test.ts index 294831966..a02fe87b6 100644 --- a/src/renderer/utils/zoom.test.ts +++ b/src/renderer/utils/zoom.test.ts @@ -2,11 +2,20 @@ import type { Percentage } from '../types'; import { canDecreaseZoom, canIncreaseZoom, + decreaseZoom, + increaseZoom, + resetZoomLevel, zoomLevelToPercentage, zoomPercentageToLevel, } from './zoom'; describe('renderer/utils/zoom.ts', () => { + window.gitify.zoom.setLevel = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('should convert percentage to zoom level', () => { expect(zoomPercentageToLevel(100 as Percentage)).toBe(0); expect(zoomPercentageToLevel(50 as Percentage)).toBe(-1); @@ -37,4 +46,38 @@ describe('renderer/utils/zoom.ts', () => { expect(canIncreaseZoom(120 as Percentage)).toBe(false); expect(canIncreaseZoom(150 as Percentage)).toBe(false); }); + + describe('decrease zoom', () => { + it('can decrease zoom within allowed range', () => { + decreaseZoom(100 as Percentage); + + expect(window.gitify.zoom.setLevel).toHaveBeenCalledWith(-0.2); + }); + + it('cannot decrease zoom outside of allowed range', () => { + decreaseZoom(0 as Percentage); + + expect(window.gitify.zoom.setLevel).not.toHaveBeenCalled(); + }); + }); + + describe('increase zoom', () => { + it('can increase zoom within allowed range', () => { + increaseZoom(100 as Percentage); + + expect(window.gitify.zoom.setLevel).toHaveBeenCalledWith(0.2); + }); + + it('cannot increase zoom outside of allowed range', () => { + increaseZoom(120 as Percentage); + + expect(window.gitify.zoom.setLevel).not.toHaveBeenCalled(); + }); + }); + + it('can reset zoom level', () => { + resetZoomLevel(); + + expect(window.gitify.zoom.setLevel).toHaveBeenCalledWith(0); + }); }); diff --git a/src/renderer/utils/zoom.ts b/src/renderer/utils/zoom.ts index eb72d4289..7edcd45cf 100644 --- a/src/renderer/utils/zoom.ts +++ b/src/renderer/utils/zoom.ts @@ -71,3 +71,12 @@ export function increaseZoom(zoomPercentage: Percentage) { ); } } + +/** + * Reset zoom level + */ +export function resetZoomLevel() { + window.gitify.zoom.setLevel( + zoomPercentageToLevel(RECOMMENDED_ZOOM_PERCENTAGE), + ); +} From 106fbedf29645bbfa6fbdbc435c599d9082f95b4 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 18 Nov 2025 13:18:44 -0500 Subject: [PATCH 4/6] refactor: move resize listener to global Signed-off-by: Adam Setch --- src/renderer/context/App.test.tsx | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index b4a7a46a9..1e0c6b2ae 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -55,7 +55,7 @@ describe('renderer/context/App.tsx', () => { const markNotificationsAsDoneMock = jest.fn(); const unsubscribeNotificationMock = jest.fn(); - const zoomTimeout = () => new Promise((r) => setTimeout(r, 300)); + // Removed unused zoomTimeout const saveStateSpy = jest .spyOn(storage, 'saveState') @@ -329,22 +329,4 @@ describe('renderer/context/App.tsx', () => { }); }); }); - - describe('zoom listeners', () => { - const updateSettingMock = jest.fn(); - - it('should update the zoom value when using CMD + and CMD -', async () => { - window.gitify.zoom.getLevel = jest.fn().mockReturnValue(-1); - - renderWithAppContext({null}, { - updateSetting: updateSettingMock, - }); - - fireEvent(window, new Event('resize')); - await zoomTimeout(); - - expect(updateSettingMock).toHaveBeenCalledTimes(1); - expect(updateSettingMock).toHaveBeenCalledWith('zoomPercentage', 50); - }); - }); }); From 3dc24b4972664dbafb617ae7deb06b37c2240e83 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 18 Nov 2025 13:20:19 -0500 Subject: [PATCH 5/6] refactor: move resize listener to global Signed-off-by: Adam Setch --- src/renderer/context/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index bcac45f4e..d84d5d80a 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -278,7 +278,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [updateSetting, settings], ); - // Global window resize listener to sync zoom percentage + // Global window zoom handler / listener // biome-ignore lint/correctness/useExhaustiveDependencies: We want to update on settings.zoomPercentage changes useEffect(() => { // Set the zoom level when settings.zoomPercentage changes From ba89c7c396f0a983abeda7523d6191212a967cff Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 19 Nov 2025 06:06:15 -0500 Subject: [PATCH 6/6] refactor: state restore Signed-off-by: Adam Setch --- src/renderer/context/App.tsx | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index d84d5d80a..82856d277 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -130,7 +130,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [unreadNotificationCount], ); - const restoreSettings = useCallback(async () => { + const restorePersistedState = useCallback(async () => { const existing = loadState(); // Restore settings before accounts to ensure filters are available before fetching notifications @@ -141,35 +141,29 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { if (existing.auth) { setAuth({ ...defaultAuth, ...existing.auth }); - // Trigger the effect to refresh accounts and handle token encryption + // Trigger the effect to refresh accounts setNeedsAccountRefresh(true); } }, []); useEffect(() => { - restoreSettings(); - }, [restoreSettings]); + restorePersistedState(); + }, [restorePersistedState]); - // Refresh account details on startup or restore + // Refresh account details on startup useEffect(() => { - if (!needsAccountRefresh || auth.accounts.length === 0) { + if (!needsAccountRefresh) { return; } - (async () => { - for (const account of auth.accounts) { - await refreshAccount(account); - } - + Promise.all(auth.accounts.map(refreshAccount)).finally(() => { setNeedsAccountRefresh(false); - })(); + }); }, [needsAccountRefresh, auth.accounts]); // Refresh account details on interval useIntervalTimer(() => { - for (const account of auth.accounts) { - refreshAccount(account); - } + Promise.all(auth.accounts.map(refreshAccount)); }, Constants.REFRESH_ACCOUNTS_INTERVAL_MS); useEffect(() => {