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
2 changes: 1 addition & 1 deletion src/renderer/components/settings/SystemSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
45 changes: 8 additions & 37 deletions src/renderer/utils/system/audio.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
77 changes: 20 additions & 57 deletions src/renderer/utils/system/audio.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
25 changes: 15 additions & 10 deletions src/renderer/utils/system/comms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
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<string> {
return await window.gitify.decryptValue(value);
}

/**
* Quit the Electron application.
* Quit the application.
*/
export function quitApp(): void {
window.gitify.app.quit();
Expand All @@ -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.
*/
Expand All @@ -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);
Expand All @@ -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<void> {
await navigator.clipboard.writeText(text);
}
1 change: 0 additions & 1 deletion src/renderer/utils/system/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/utils/system/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
) {
Expand Down
45 changes: 45 additions & 0 deletions src/renderer/utils/ui/volume.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions src/renderer/utils/ui/volume.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading