Skip to content
Merged
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
82 changes: 15 additions & 67 deletions src/renderer/components/settings/AppearanceSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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(<AppearanceSettings />, {
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(<AppearanceSettings />, {
Expand All @@ -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 () => {
Expand Down
25 changes: 4 additions & 21 deletions src/renderer/components/settings/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FC, useContext, useState } from 'react';
import { type FC, useContext } from 'react';

import {
PaintbrushIcon,
Expand All @@ -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 (
<fieldset>
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/context/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
116 changes: 69 additions & 47 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import {
removeAccount,
} from '../utils/auth/utils';
import {
decryptValue,
encryptValue,
setAutoLaunch,
setKeyboardShortcut,
Expand All @@ -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 {
Expand Down Expand Up @@ -106,6 +105,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
const { setColorMode, setDayScheme, setNightScheme } = useTheme();
const [auth, setAuth] = useState<AuthState>(defaultAuth);
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [needsAccountRefresh, setNeedsAccountRefresh] = useState(false);

const {
removeAccountNotifications,
fetchNotifications,
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -208,7 +234,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
}, [settings.openAtStartup]);

useEffect(() => {
window.gitify.onResetApp(() => {
globalThis.gitify.onResetApp(() => {
clearState();
setAuth(defaultAuth);
setSettings(defaultSettings);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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],
Expand Down
Loading
Loading