From 1e039ebfce4c2e2c57fcabe9af097089ed88a196 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 24 Mar 2026 10:15:22 -0400 Subject: [PATCH] refactor(utils/system): alignment and jsdocs Signed-off-by: Adam Setch --- .../components/settings/SystemSettings.tsx | 2 +- src/renderer/utils/system/audio.test.ts | 45 ++--------- src/renderer/utils/system/audio.ts | 77 +++++-------------- src/renderer/utils/system/comms.ts | 25 +++--- src/renderer/utils/system/native.test.ts | 1 - src/renderer/utils/system/native.ts | 9 +++ src/renderer/utils/ui/volume.test.ts | 45 +++++++++++ src/renderer/utils/ui/volume.ts | 63 +++++++++++++++ 8 files changed, 161 insertions(+), 106 deletions(-) create mode 100644 src/renderer/utils/ui/volume.test.ts create mode 100644 src/renderer/utils/ui/volume.ts diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 877121a9d..038b3a9d1 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -19,7 +19,7 @@ import { canIncreaseVolume, decreaseVolume, increaseVolume, -} from '../../utils/system/audio'; +} from '../../utils/ui/volume'; import { VolumeDownIcon } from '../icons/VolumeDownIcon'; import { VolumeUpIcon } from '../icons/VolumeUpIcon'; diff --git a/src/renderer/utils/system/audio.test.ts b/src/renderer/utils/system/audio.test.ts index f5a4bb6df..d903105a8 100644 --- a/src/renderer/utils/system/audio.test.ts +++ b/src/renderer/utils/system/audio.test.ts @@ -1,45 +1,16 @@ import type { Percentage } from '../../types'; -import { - canDecreaseVolume, - canIncreaseVolume, - decreaseVolume, - increaseVolume, - volumePercentageToLevel, -} from './audio'; +import { raiseSoundNotification } from './audio'; describe('renderer/utils/system/audio.ts', () => { - it('should convert percentage to sound level', () => { - expect(volumePercentageToLevel(100 as Percentage)).toBe(1); - expect(volumePercentageToLevel(50 as Percentage)).toBe(0.5); - expect(volumePercentageToLevel(0 as Percentage)).toBe(0); - }); - - it('can decrease volume percentage', () => { - expect(canDecreaseVolume(-10 as Percentage)).toBe(false); - expect(canDecreaseVolume(0 as Percentage)).toBe(false); - expect(canDecreaseVolume(10 as Percentage)).toBe(true); - expect(canDecreaseVolume(100 as Percentage)).toBe(true); - }); + describe('raiseSoundNotification', () => { + it('should play sound at correct volume', async () => { + const audioPlaySpy = vi.spyOn(Audio.prototype, 'play'); - it('should decrease volume by step amount', () => { - expect(decreaseVolume(100 as Percentage)).toBe(90); - expect(decreaseVolume(50 as Percentage)).toBe(40); - expect(decreaseVolume(0 as Percentage)).toBe(0); - expect(decreaseVolume(-10 as Percentage)).toBe(0); - }); - - it('can increase volume percentage', () => { - expect(canIncreaseVolume(10 as Percentage)).toBe(true); - expect(canIncreaseVolume(90 as Percentage)).toBe(true); - expect(canIncreaseVolume(100 as Percentage)).toBe(false); - expect(canIncreaseVolume(110 as Percentage)).toBe(false); - }); + await raiseSoundNotification(50 as Percentage); - it('should increase volume by step amount', () => { - expect(increaseVolume(0 as Percentage)).toBe(10); - expect(increaseVolume(50 as Percentage)).toBe(60); - expect(increaseVolume(100 as Percentage)).toBe(100); - expect(increaseVolume(110 as Percentage)).toBe(100); + expect(window.gitify.notificationSoundPath).toHaveBeenCalled(); + expect(audioPlaySpy).toHaveBeenCalled(); + }); }); }); diff --git a/src/renderer/utils/system/audio.ts b/src/renderer/utils/system/audio.ts index 17a6e101c..059212c31 100644 --- a/src/renderer/utils/system/audio.ts +++ b/src/renderer/utils/system/audio.ts @@ -1,70 +1,33 @@ import type { Percentage } from '../../types'; -const MINIMUM_VOLUME_PERCENTAGE = 0 as Percentage; -const MAXIMUM_VOLUME_PERCENTAGE = 100 as Percentage; -const VOLUME_STEP = 10 as Percentage; +import { rendererLogError } from '../core/logger'; +import { volumePercentageToLevel } from '../ui/volume'; + +// Cache audio instance to avoid re-creating elements on every notification. +let cachedAudio: HTMLAudioElement | null = null; /** - * Play the user's configured notification sound at the given volume. - * - * Resolves the notification sound file path from the main process, then - * plays it via the Web Audio API. + * Plays the notification sound at the specified volume. + * The audio element is lazily created and cached to avoid re-creating it on every call. + * The cache is cleared if playback fails so the next call can retry. * - * @param volume - The playback volume as a percentage (0–100). + * @param volume - The volume level to play the sound at, as a percentage (`0`–`100`). */ export async function raiseSoundNotification(volume: Percentage) { - const path = await window.gitify.notificationSoundPath(); - - const audio = new Audio(path); - audio.volume = volumePercentageToLevel(volume); - audio.play(); -} - -/** - * Convert volume percentage (0-100) to level (0.0-1.0) - */ -export function volumePercentageToLevel(percentage: Percentage): number { - return percentage / 100; -} + if (!cachedAudio) { + const path = await window.gitify.notificationSoundPath(); -/** - * Returns true if can decrease volume percentage further - */ -export function canDecreaseVolume(volumePercentage: Percentage) { - return volumePercentage - VOLUME_STEP >= MINIMUM_VOLUME_PERCENTAGE; -} - -/** - * Returns true if can increase volume percentage further - */ -export function canIncreaseVolume(volumePercentage: Percentage) { - return volumePercentage + VOLUME_STEP <= MAXIMUM_VOLUME_PERCENTAGE; -} - -/** - * Decrease the volume by one step, clamped to the minimum. - * - * @param volume - The current volume percentage. - * @returns The new volume percentage after decrement, or the minimum if already at the floor. - */ -export function decreaseVolume(volume: Percentage) { - if (canDecreaseVolume(volume)) { - return volume - VOLUME_STEP; + cachedAudio = new Audio(path); } - return MINIMUM_VOLUME_PERCENTAGE; -} + const audio = cachedAudio; -/** - * Increase the volume by one step, clamped to the maximum. - * - * @param volume - The current volume percentage. - * @returns The new volume percentage after increment, or the maximum if already at the ceiling. - */ -export function increaseVolume(volume: Percentage) { - if (canIncreaseVolume(volume)) { - return volume + VOLUME_STEP; - } + audio.volume = volumePercentageToLevel(volume); - return MAXIMUM_VOLUME_PERCENTAGE; + try { + await audio.play(); + } catch (err) { + rendererLogError('audio', 'Failed to play notification sound:', err); + cachedAudio = null; + } } diff --git a/src/renderer/utils/system/comms.ts b/src/renderer/utils/system/comms.ts index 764621e32..6b79f7c44 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -28,36 +28,36 @@ export function openExternalLink(url: Link): void { } /** - * Return the application version string from the main process. + * Returns the current application version string. * - * @returns The version string (e.g. `"5.12.0"`). + * @returns Promise resolving to the app version (e.g. `"6.18.0"`). */ export async function getAppVersion(): Promise { return await window.gitify.app.version(); } /** - * Encrypt a plaintext value using Electron's safe storage. + * Encrypts a plaintext string using the native Electron encryption bridge. * * @param value - The plaintext string to encrypt. - * @returns The encrypted string. + * @returns Promise resolving to the encrypted string. */ export async function encryptValue(value: string): Promise { return await window.gitify.encryptValue(value); } /** - * Decrypt an encrypted value using Electron's safe storage. + * Decrypts a previously encrypted string using the native Electron decryption bridge. * * @param value - The encrypted string to decrypt. - * @returns The plaintext string. + * @returns Promise resolving to the decrypted plaintext string. */ export async function decryptValue(value: string): Promise { return await window.gitify.decryptValue(value); } /** - * Quit the Electron application. + * Quit the application. */ export function quitApp(): void { window.gitify.app.quit(); @@ -78,7 +78,7 @@ export function hideWindow(): void { } /** - * Enable or disable launching the application at system login. + * Enables or disables auto-launch of the application on system startup. * * @param value - `true` to enable auto-launch, `false` to disable. */ @@ -105,9 +105,9 @@ export function setUseUnreadActiveIcon(value: boolean): void { } /** - * Register or unregister the global keyboard shortcut for the application. + * Registers or unregisters the global keyboard shortcut to toggle the application window. * - * @param keyboardShortcut - `true` to enable the shortcut, `false` to disable. + * @param keyboardShortcut - `true` to register the shortcut, `false` to unregister. */ export function setKeyboardShortcut(keyboardShortcut: boolean): void { window.gitify.setKeyboardShortcut(keyboardShortcut); @@ -133,6 +133,11 @@ export function updateTrayTitle(title: string): void { window.gitify.tray.updateTitle(title); } +/** + * Copies the specified text to the system clipboard. + * + * @param text - The text to copy to the clipboard. + */ export async function copyToClipboard(text: string): Promise { await navigator.clipboard.writeText(text); } diff --git a/src/renderer/utils/system/native.test.ts b/src/renderer/utils/system/native.test.ts index 1f28fac0d..5d5158af7 100644 --- a/src/renderer/utils/system/native.test.ts +++ b/src/renderer/utils/system/native.test.ts @@ -21,7 +21,6 @@ describe('renderer/utils/system/native.ts', () => { mockSingleAccountNotifications[0].notifications, ); - // wait for async native handling (generateGitHubWebUrl) to complete await waitFor(() => expect(window.gitify.raiseNativeNotification).toHaveBeenCalledTimes(1), ); diff --git a/src/renderer/utils/system/native.ts b/src/renderer/utils/system/native.ts index a85602813..65fcd990f 100644 --- a/src/renderer/utils/system/native.ts +++ b/src/renderer/utils/system/native.ts @@ -4,6 +4,15 @@ import type { GitifyNotification } from '../../types'; import { generateGitHubWebUrl } from '../notifications/url'; +/** + * Raises a native OS notification. + * + * For a single notification, the message is used as the title (non-Windows) and the + * formatted footer text is used as the body. For multiple notifications, a generic count + * summary is shown instead. + * + * @param notifications - The notifications to surface as a native OS notification. + */ export async function raiseNativeNotification( notifications: GitifyNotification[], ) { diff --git a/src/renderer/utils/ui/volume.test.ts b/src/renderer/utils/ui/volume.test.ts new file mode 100644 index 000000000..a50c292af --- /dev/null +++ b/src/renderer/utils/ui/volume.test.ts @@ -0,0 +1,45 @@ +import type { Percentage } from '../../types'; + +import { + canDecreaseVolume, + canIncreaseVolume, + decreaseVolume, + increaseVolume, + volumePercentageToLevel, +} from './volume'; + +describe('renderer/utils/ui/volume.ts', () => { + it('should convert percentage to sound level', () => { + expect(volumePercentageToLevel(100 as Percentage)).toBe(1); + expect(volumePercentageToLevel(50 as Percentage)).toBe(0.5); + expect(volumePercentageToLevel(0 as Percentage)).toBe(0); + }); + + it('can decrease volume percentage', () => { + expect(canDecreaseVolume(-10 as Percentage)).toBe(false); + expect(canDecreaseVolume(0 as Percentage)).toBe(false); + expect(canDecreaseVolume(10 as Percentage)).toBe(true); + expect(canDecreaseVolume(100 as Percentage)).toBe(true); + }); + + it('should decrease volume by step amount', () => { + expect(decreaseVolume(100 as Percentage)).toBe(90); + expect(decreaseVolume(50 as Percentage)).toBe(40); + expect(decreaseVolume(0 as Percentage)).toBe(0); + expect(decreaseVolume(-10 as Percentage)).toBe(0); + }); + + it('can increase volume percentage', () => { + expect(canIncreaseVolume(10 as Percentage)).toBe(true); + expect(canIncreaseVolume(90 as Percentage)).toBe(true); + expect(canIncreaseVolume(100 as Percentage)).toBe(false); + expect(canIncreaseVolume(110 as Percentage)).toBe(false); + }); + + it('should increase volume by step amount', () => { + expect(increaseVolume(0 as Percentage)).toBe(10); + expect(increaseVolume(50 as Percentage)).toBe(60); + expect(increaseVolume(100 as Percentage)).toBe(100); + expect(increaseVolume(110 as Percentage)).toBe(100); + }); +}); diff --git a/src/renderer/utils/ui/volume.ts b/src/renderer/utils/ui/volume.ts new file mode 100644 index 000000000..1e612c894 --- /dev/null +++ b/src/renderer/utils/ui/volume.ts @@ -0,0 +1,63 @@ +import type { Percentage } from '../../types'; + +const MINIMUM_VOLUME_PERCENTAGE = 0 as Percentage; +const MAXIMUM_VOLUME_PERCENTAGE = 100 as Percentage; +const VOLUME_STEP = 10 as Percentage; + +/** + * Convert volume percentage (0-100) to level (0.0-1.0). + * + * @param percentage - Volume percentage in the range `0`–`100`. + * @returns Volume level in the range `0.0`–`1.0`. + */ +export function volumePercentageToLevel(percentage: Percentage): number { + return percentage / 100; +} + +/** + * Returns `true` if the volume can be decreased by one step. + * + * @param volumePercentage - Current volume percentage. + * @returns `true` if decreasing by one step would remain at or above the minimum, `false` otherwise. + */ +export function canDecreaseVolume(volumePercentage: Percentage) { + return volumePercentage - VOLUME_STEP >= MINIMUM_VOLUME_PERCENTAGE; +} + +/** + * Returns `true` if the volume can be increased by one step. + * + * @param volumePercentage - Current volume percentage. + * @returns `true` if increasing by one step would remain at or below the maximum, `false` otherwise. + */ +export function canIncreaseVolume(volumePercentage: Percentage) { + return volumePercentage + VOLUME_STEP <= MAXIMUM_VOLUME_PERCENTAGE; +} + +/** + * Decreases the volume by one step, clamping to the minimum. + * + * @param volume - Current volume percentage. + * @returns The new volume percentage after decreasing, or `0` if already at minimum. + */ +export function decreaseVolume(volume: Percentage): Percentage { + if (canDecreaseVolume(volume)) { + return (volume - VOLUME_STEP) as Percentage; + } + + return MINIMUM_VOLUME_PERCENTAGE; +} + +/** + * Increases the volume by one step, clamping to the maximum. + * + * @param volume - Current volume percentage. + * @returns The new volume percentage after increasing, or `100` if already at maximum. + */ +export function increaseVolume(volume: Percentage): Percentage { + if (canIncreaseVolume(volume)) { + return (volume + VOLUME_STEP) as Percentage; + } + + return MAXIMUM_VOLUME_PERCENTAGE; +}