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..1e0c6b2ae 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(); + // Removed unused zoomTimeout + const saveStateSpy = jest .spyOn(storage, 'saveState') .mockImplementation(jest.fn()); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 190f89e9f..82856d277 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, @@ -60,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 { @@ -106,6 +105,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, @@ -129,11 +130,42 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [unreadNotificationCount], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: restoreSettings is stable and should run only once - useEffect(() => { - restoreSettings(); + const restorePersistedState = 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 + setNeedsAccountRefresh(true); + } }, []); + useEffect(() => { + restorePersistedState(); + }, [restorePersistedState]); + + // Refresh account details on startup + useEffect(() => { + if (!needsAccountRefresh) { + return; + } + + Promise.all(auth.accounts.map(refreshAccount)).finally(() => { + setNeedsAccountRefresh(false); + }); + }, [needsAccountRefresh, auth.accounts]); + + // Refresh account details on interval + useIntervalTimer(() => { + Promise.all(auth.accounts.map(refreshAccount)); + }, Constants.REFRESH_ACCOUNTS_INTERVAL_MS); + useEffect(() => { const colorMode = mapThemeModeToColorMode(settings.theme); const colorScheme = mapThemeModeToColorScheme( @@ -179,12 +211,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 +234,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [settings.openAtStartup]); useEffect(() => { - window.gitify.onResetApp(() => { + globalThis.gitify.onResetApp(() => { clearState(); setAuth(defaultAuth); setSettings(defaultSettings); @@ -246,6 +272,37 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [updateSetting, settings], ); + // 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 + 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]); @@ -304,41 +361,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) { - 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); - } - } - }, []); - const fetchNotificationsWithAccounts = useCallback( async () => await fetchNotifications({ auth, settings }), [auth, settings, fetchNotifications], 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), + ); +}