Skip to content
Open
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
50 changes: 50 additions & 0 deletions app/internal_packages/custom-sounds/lib/notification-sound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';

export const NOTIFICATION_SOUND_VOLUME_CONFIG_KEY = 'core.notifications.soundVolume';
export const CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY = 'core.notifications.customSoundPath';
export const SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS = ['mp3', 'ogg', 'wav', 'm4a', 'aac', 'flac'];
export const MAX_CUSTOM_NOTIFICATION_SOUND_BYTES = 25 * 1024 * 1024;

type FileStats = { size: number; isFile: () => boolean };
type StatFile = (candidate: string) => FileStats;

export function isSupportedNotificationSoundPath(filePath: unknown): filePath is string {
if (typeof filePath !== 'string' || !filePath) return false;
const extension = path.extname(filePath).slice(1).toLowerCase();
return SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS.includes(extension);
}

export function resolveCustomNotificationSound(
filePath: unknown,
statFile: StatFile = fs.statSync
): string | undefined {
if (!isSupportedNotificationSoundPath(filePath) || !path.isAbsolute(filePath)) return undefined;

try {
const stats = statFile(filePath);
if (!stats.isFile() || stats.size < 1 || stats.size > MAX_CUSTOM_NOTIFICATION_SOUND_BYTES) {
return undefined;
}
} catch (error) {
return undefined;
}

return pathToFileURL(path.resolve(filePath)).toString();
}

export function notificationSoundPlaybackOptions(
config: { get: (key: string) => any },
statFile: StatFile = fs.statSync
) {
const volumePercent = Number(config.get(NOTIFICATION_SOUND_VOLUME_CONFIG_KEY));
const source = resolveCustomNotificationSound(
config.get(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY),
statFile
);
return {
volume: volumePercent / 100,
...(source ? { source } : {}),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import path from 'path';
import { pathToFileURL } from 'url';
import {
CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY,
MAX_CUSTOM_NOTIFICATION_SOUND_BYTES,
NOTIFICATION_SOUND_VOLUME_CONFIG_KEY,
isSupportedNotificationSoundPath,
notificationSoundPlaybackOptions,
resolveCustomNotificationSound,
} from '../lib/notification-sound';

describe('notification sound settings', () => {
const regularFile =
(size = 1024) =>
() => ({ size, isFile: () => true });

it('recognizes supported extensions case-insensitively', () => {
expect(isSupportedNotificationSoundPath('/sounds/mail.ogg')).toBe(true);
expect(isSupportedNotificationSoundPath('/sounds/mail.MP3')).toBe(true);
expect(isSupportedNotificationSoundPath('/sounds/mail.txt')).toBe(false);
expect(isSupportedNotificationSoundPath('')).toBe(false);
});

it('resolves an existing supported file to a file URL', () => {
const filePath = path.resolve('/sounds/mail tone.ogg');
expect(resolveCustomNotificationSound(filePath, regularFile())).toBe(
pathToFileURL(filePath).toString()
);
});

it('falls back for missing, unsupported, and relative files', () => {
const missingFile = () => {
throw new Error('ENOENT');
};
expect(
resolveCustomNotificationSound(path.resolve('/sounds/missing.ogg'), missingFile)
).toBeUndefined();
expect(
resolveCustomNotificationSound(path.resolve('/sounds/mail.txt'), regularFile())
).toBeUndefined();
expect(resolveCustomNotificationSound('sounds/mail.ogg', regularFile())).toBeUndefined();
expect(resolveCustomNotificationSound(undefined, regularFile())).toBeUndefined();
});

it('falls back for non-regular, empty, and oversized files', () => {
const directory = () => ({ size: 1024, isFile: () => false });
const filePath = path.resolve('/sounds/mail.ogg');

expect(resolveCustomNotificationSound(filePath, directory)).toBeUndefined();
expect(resolveCustomNotificationSound(filePath, regularFile(0))).toBeUndefined();
expect(
resolveCustomNotificationSound(filePath, regularFile(MAX_CUSTOM_NOTIFICATION_SOUND_BYTES + 1))
).toBeUndefined();
expect(
resolveCustomNotificationSound(filePath, regularFile(MAX_CUSTOM_NOTIFICATION_SOUND_BYTES))
).toBe(pathToFileURL(filePath).toString());
});

it('builds playback options from independent volume and custom-path preferences', () => {
const customPath = path.resolve('/sounds/mail.ogg');
const config = {
get: (key) => {
if (key === NOTIFICATION_SOUND_VOLUME_CONFIG_KEY) return 25;
if (key === CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY) return customPath;
return undefined;
},
};

expect(notificationSoundPlaybackOptions(config, regularFile())).toEqual({
volume: 0.25,
source: pathToFileURL(customPath).toString(),
});
expect(
notificationSoundPlaybackOptions(config, () => {
throw new Error('ENOENT');
})
).toEqual({ volume: 0.25 });
});
});
28 changes: 28 additions & 0 deletions app/internal_packages/preferences/lib/tabs/config-schema-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class ConfigSchemaItem extends React.Component<ConfigSchemaItemProps> {
event.target.blur();
};

_onChangeNumber = (event) => {
this.props.config.set(this.props.keyPath, Number(event.target.value));
};

render() {
if (!this._appliesToPlatform()) return false;

Expand Down Expand Up @@ -100,6 +104,30 @@ class ConfigSchemaItem extends React.Component<ConfigSchemaItemProps> {
{note}
</div>
);
} else if (
this.props.configSchema.type === 'number' &&
this.props.configSchema.minimum !== undefined &&
this.props.configSchema.maximum !== undefined
) {
const value = Number(this.props.config.get(this.props.keyPath));
return (
<div className="item config-schema-range-item">
<label htmlFor={this.props.keyPath}>
{this.props.configSchema.title}:{' '}
<output>{`${value}${this.props.configSchema.unit || ''}`}</output>
</label>
<input
id={this.props.keyPath}
type="range"
min={this.props.configSchema.minimum}
max={this.props.configSchema.maximum}
step={this.props.configSchema.multipleOf || 1}
value={value}
onChange={this._onChangeNumber}
/>
{note}
</div>
);
}
return <span />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import path from 'path';
import { localized, SoundRegistry } from 'mailspring-exports';
import ConfigSchemaItem from './config-schema-item';
import { ConfigLike, ConfigSchemaLike } from '../types';
import {
CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY,
SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS,
isSupportedNotificationSoundPath,
notificationSoundPlaybackOptions,
resolveCustomNotificationSound,
} from '../../../custom-sounds/lib/notification-sound';

export default class NotificationsSection extends React.Component<{
config: ConfigLike;
configSchema: ConfigSchemaLike;
}> {
_chooseSound = () => {
AppEnv.showOpenDialog(
{
title: localized('Choose a notification sound'),
buttonLabel: localized('Choose'),
properties: ['openFile'],
filters: [
{
name: localized('Audio files'),
extensions: SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS,
},
],
},
(paths) => {
if (!paths || paths.length === 0) return;
if (!isSupportedNotificationSoundPath(paths[0])) {
AppEnv.showErrorDialog(
localized(
'Please choose an audio file with one of these extensions: %@',
SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS.join(', ')
)
);
return;
}
this.props.config.set(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, paths[0]);
}
);
};

_resetSound = () => {
this.props.config.set(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, '');
};

_playTestSound = () => {
SoundRegistry.playSound('new-mail', notificationSoundPlaybackOptions(this.props.config));
};

render() {
const customPath = this.props.config.get(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY);
const customSource = resolveCustomNotificationSound(customPath);
return (
<div>
<ConfigSchemaItem
configSchema={this.props.configSchema}
keyName={localized('Notifications')}
keyPath="core.notifications"
config={this.props.config}
/>
<div className="notification-sound-controls">
<div className="notification-sound-file">
{customPath
? customSource
? localized('Custom sound: %@', path.basename(customPath))
: localized('Custom sound unavailable; using the default sound')
: localized('Using the default notification sound')}
</div>
<button className="btn btn-small" onClick={this._chooseSound}>
{localized('Choose Sound…')}
</button>
{customPath && (
<button className="btn btn-small" onClick={this._resetSound}>
{localized('Reset to Default')}
</button>
)}
<button className="btn btn-small" onClick={this._playTestSound}>
{localized('Play Test Sound')}
</button>
</div>
</div>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ConfigSchemaItem from './config-schema-item';
import WorkspaceSection from './workspace-section';
import SendingSection from './sending-section';
import LanguageSection from './language-section';
import NotificationsSection from './notifications-section';
import { ConfigLike, ConfigSchemaLike } from '../types';

class PreferencesGeneral extends React.Component<{
Expand Down Expand Up @@ -95,10 +96,8 @@ class PreferencesGeneral extends React.Component<{

<div className="two-columns-flexbox" style={{ paddingTop: 30 }}>
<div style={{ flex: 1 }}>
<ConfigSchemaItem
<NotificationsSection
configSchema={this.props.configSchema.properties.notifications}
keyName={localized('Notifications')}
keyPath="core.notifications"
config={this.props.config}
/>
</div>
Expand Down
7 changes: 6 additions & 1 deletion app/internal_packages/preferences/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export interface ConfigLike {
get: (key: string) => string;
get: (key: string) => any;
toggle: (key: string) => void;
set: (key: string, val: any) => void;
}
Expand All @@ -15,4 +15,9 @@ export interface ConfigSchemaLike {
enum?: string;
enumLabels: string;
platforms?: string[];
platform?: string;
minimum?: number;
maximum?: number;
multipleOf?: number;
unit?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import ConfigSchemaItem from '../lib/tabs/config-schema-item';

describe('ConfigSchemaItem', () => {
afterEach(cleanup);

it('renders and persists a bounded numeric range value as a number', () => {
const config = {
get: jasmine.createSpy('get').andReturn(65),
set: jasmine.createSpy('set'),
toggle: jasmine.createSpy('toggle'),
};
const configSchema = {
type: 'number',
title: 'Notification sound volume',
minimum: 0,
maximum: 100,
multipleOf: 1,
unit: '%',
} as any;

const { container } = render(
<ConfigSchemaItem
keyPath="core.notifications.soundVolume"
config={config}
configSchema={configSchema}
/>
);

const range = container.querySelector('input[type="range"]') as HTMLInputElement;
expect(range.min).toBe('0');
expect(range.max).toBe('100');
expect(range.value).toBe('65');
expect(container.querySelector('output').textContent).toBe('65%');

fireEvent.change(range, { target: { value: '40' } });
expect(config.set).toHaveBeenCalledWith('core.notifications.soundVolume', 40);
});
});
Loading