feat(calendar): add event notifications with alert evaluation
Add client-side calendar event notification system that evaluates JMAP CalendarEventAlert triggers and displays toast notifications when alert times are reached. Includes configurable notification sound, acknowledged alert persistence, and proactive event fetching for background alerts. Also mounts ToastContainer globally to fix silent toast failures.
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAlertOffset,
|
||||
computeFireTime,
|
||||
getEffectiveAlerts,
|
||||
buildAlertKey,
|
||||
getPendingAlerts,
|
||||
} from '../calendar-alerts';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarEventAlert,
|
||||
Calendar,
|
||||
} from '@/lib/jmap/types';
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
calendarIds: { 'cal-1': true },
|
||||
isDraft: false,
|
||||
isOrigin: true,
|
||||
utcStart: '2026-03-01T10:00:00Z',
|
||||
utcEnd: '2026-03-01T11:00:00Z',
|
||||
'@type': 'Event',
|
||||
uid: 'uid-1',
|
||||
title: 'Test Event',
|
||||
description: '',
|
||||
descriptionContentType: 'text/plain',
|
||||
created: null,
|
||||
updated: '2026-03-01T09:00:00Z',
|
||||
sequence: 0,
|
||||
start: '2026-03-01T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'UTC',
|
||||
showWithoutTime: false,
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'public',
|
||||
color: null,
|
||||
keywords: null,
|
||||
categories: null,
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
participants: null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
hideAttendees: false,
|
||||
recurrenceId: null,
|
||||
recurrenceIdTimeZone: null,
|
||||
recurrenceRules: null,
|
||||
recurrenceOverrides: null,
|
||||
excludedRecurrenceRules: null,
|
||||
useDefaultAlerts: false,
|
||||
alerts: null,
|
||||
locations: null,
|
||||
virtualLocations: null,
|
||||
links: null,
|
||||
relatedTo: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
|
||||
return {
|
||||
id: 'cal-1',
|
||||
name: 'Work',
|
||||
description: null,
|
||||
color: '#0000ff',
|
||||
sortOrder: 1,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: true,
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayAdmin: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAlert(overrides: Partial<CalendarEventAlert> = {}): CalendarEventAlert {
|
||||
return {
|
||||
'@type': 'Alert',
|
||||
trigger: {
|
||||
'@type': 'OffsetTrigger',
|
||||
offset: '-PT5M',
|
||||
relativeTo: 'start',
|
||||
},
|
||||
action: 'display',
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseAlertOffset', () => {
|
||||
it('parses negative minutes', () => {
|
||||
expect(parseAlertOffset('-PT5M')).toBe(-5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses positive minutes', () => {
|
||||
expect(parseAlertOffset('PT10M')).toBe(10 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses zero duration', () => {
|
||||
expect(parseAlertOffset('PT0S')).toBe(0);
|
||||
});
|
||||
|
||||
it('parses hours', () => {
|
||||
expect(parseAlertOffset('-PT1H')).toBe(-60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses days', () => {
|
||||
expect(parseAlertOffset('-P1D')).toBe(-24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses complex duration', () => {
|
||||
expect(parseAlertOffset('-P1DT2H30M')).toBe(-(24 * 60 * 60 + 2 * 60 * 60 + 30 * 60) * 1000);
|
||||
});
|
||||
|
||||
it('returns null for invalid format', () => {
|
||||
expect(parseAlertOffset('invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses positive day duration', () => {
|
||||
expect(parseAlertOffset('P2D')).toBe(2 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses seconds only', () => {
|
||||
expect(parseAlertOffset('PT30S')).toBe(30 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeFireTime', () => {
|
||||
it('computes offset from utcStart', () => {
|
||||
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
|
||||
const trigger = { '@type': 'OffsetTrigger' as const, offset: '-PT5M', relativeTo: 'start' as const };
|
||||
const expected = new Date('2026-03-01T10:00:00Z').getTime() - 5 * 60 * 1000;
|
||||
expect(computeFireTime(event, trigger)).toBe(expected);
|
||||
});
|
||||
|
||||
it('falls back to start when utcStart is null', () => {
|
||||
const event = makeEvent({ utcStart: null, start: '2026-03-01T10:00:00' });
|
||||
const trigger = { '@type': 'OffsetTrigger' as const, offset: '-PT10M', relativeTo: 'start' as const };
|
||||
const expected = new Date('2026-03-01T10:00:00').getTime() - 10 * 60 * 1000;
|
||||
expect(computeFireTime(event, trigger)).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles absolute trigger', () => {
|
||||
const event = makeEvent();
|
||||
const trigger = { '@type': 'AbsoluteTrigger' as const, when: '2026-03-01T09:55:00Z' };
|
||||
expect(computeFireTime(event, trigger)).toBe(new Date('2026-03-01T09:55:00Z').getTime());
|
||||
});
|
||||
|
||||
it('handles zero offset (at time of event)', () => {
|
||||
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
|
||||
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT0S', relativeTo: 'start' as const };
|
||||
expect(computeFireTime(event, trigger)).toBe(new Date('2026-03-01T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('handles positive offset (after start)', () => {
|
||||
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
|
||||
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT15M', relativeTo: 'start' as const };
|
||||
const expected = new Date('2026-03-01T10:00:00Z').getTime() + 15 * 60 * 1000;
|
||||
expect(computeFireTime(event, trigger)).toBe(expected);
|
||||
});
|
||||
|
||||
it('computes offset from utcEnd when relativeTo is end', () => {
|
||||
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z', utcEnd: '2026-03-01T11:00:00Z' });
|
||||
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT5M', relativeTo: 'end' as const };
|
||||
const expected = new Date('2026-03-01T11:00:00Z').getTime() + 5 * 60 * 1000;
|
||||
expect(computeFireTime(event, trigger)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns null for invalid offset', () => {
|
||||
const event = makeEvent();
|
||||
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'garbage', relativeTo: 'start' as const };
|
||||
expect(computeFireTime(event, trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid absolute trigger date', () => {
|
||||
const event = makeEvent();
|
||||
const trigger = { '@type': 'AbsoluteTrigger' as const, when: 'not-a-date' };
|
||||
expect(computeFireTime(event, trigger)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveAlerts', () => {
|
||||
it('returns event alerts when useDefaultAlerts is false', () => {
|
||||
const alerts = { 'a1': makeAlert() };
|
||||
const event = makeEvent({ useDefaultAlerts: false, alerts });
|
||||
const calendars = [makeCalendar()];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBe(alerts);
|
||||
});
|
||||
|
||||
it('returns null when event has no alerts and useDefaultAlerts is false', () => {
|
||||
const event = makeEvent({ useDefaultAlerts: false, alerts: null });
|
||||
const calendars = [makeCalendar()];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns calendar defaultAlertsWithTime for timed events', () => {
|
||||
const defaultAlerts = { 'd1': makeAlert() };
|
||||
const event = makeEvent({ useDefaultAlerts: true, showWithoutTime: false });
|
||||
const calendars = [makeCalendar({ defaultAlertsWithTime: defaultAlerts })];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
|
||||
});
|
||||
|
||||
it('returns calendar defaultAlertsWithoutTime for all-day events', () => {
|
||||
const defaultAlerts = { 'd1': makeAlert() };
|
||||
const event = makeEvent({ useDefaultAlerts: true, showWithoutTime: true });
|
||||
const calendars = [makeCalendar({ defaultAlertsWithoutTime: defaultAlerts })];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
|
||||
});
|
||||
|
||||
it('returns null when calendar not found', () => {
|
||||
const event = makeEvent({ useDefaultAlerts: true, calendarIds: { 'missing': true } });
|
||||
const calendars = [makeCalendar()];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when calendarIds is empty', () => {
|
||||
const event = makeEvent({ useDefaultAlerts: true, calendarIds: {} });
|
||||
const calendars = [makeCalendar()];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertKey', () => {
|
||||
it('builds deterministic key', () => {
|
||||
const key = buildAlertKey('evt-1', 'alert-1', 12345);
|
||||
expect(key).toBe('evt-1:alert-1:12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingAlerts', () => {
|
||||
const eventStart = new Date('2026-03-01T10:00:00Z').getTime();
|
||||
const fiveMinBefore = eventStart - 5 * 60 * 1000;
|
||||
|
||||
it('returns pending display alerts', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].eventId).toBe('evt-1');
|
||||
expect(result[0].alertId).toBe('a1');
|
||||
expect(result[0].calendarName).toBe('Work');
|
||||
});
|
||||
|
||||
it('skips alerts not yet due', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore - 10000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips stale alerts older than 10 minutes', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 11 * 60 * 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips alerts at exactly the stale threshold', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 10 * 60 * 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips acknowledged alerts', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const key = buildAlertKey('evt-1', 'a1', fiveMinBefore);
|
||||
const result = getPendingAlerts([event], calendars, new Set([key]), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips email action alerts', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert({ action: 'email' }) },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips server-acknowledged alerts', () => {
|
||||
const event = makeEvent({
|
||||
alerts: { 'a1': makeAlert({ acknowledged: '2026-03-01T09:55:00Z' }) },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves useDefaultAlerts from calendar defaults', () => {
|
||||
const defaultAlerts = {
|
||||
'd1': makeAlert({
|
||||
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
|
||||
}),
|
||||
};
|
||||
const event = makeEvent({
|
||||
useDefaultAlerts: true,
|
||||
alerts: null,
|
||||
});
|
||||
const calendars = [makeCalendar({ defaultAlertsWithTime: defaultAlerts })];
|
||||
const fifteenMinBefore = eventStart - 15 * 60 * 1000;
|
||||
const now = fifteenMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alertId).toBe('d1');
|
||||
});
|
||||
|
||||
it('returns multiple alerts from same event independently', () => {
|
||||
const tenMinBefore = eventStart - 10 * 60 * 1000;
|
||||
const event = makeEvent({
|
||||
alerts: {
|
||||
'a1': makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }),
|
||||
'a2': makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' } }),
|
||||
},
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = tenMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alertId).toBe('a2');
|
||||
|
||||
const nowLater = fiveMinBefore + 1000;
|
||||
const result2 = getPendingAlerts([event], calendars, new Set(), nowLater);
|
||||
expect(result2).toHaveLength(2);
|
||||
const alertIds = result2.map(r => r.alertId).sort();
|
||||
expect(alertIds).toEqual(['a1', 'a2']);
|
||||
});
|
||||
|
||||
it('handles multiple events with mixed alert states', () => {
|
||||
const evt1 = makeEvent({
|
||||
id: 'evt-1',
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const evt2 = makeEvent({
|
||||
id: 'evt-2',
|
||||
alerts: { 'a1': makeAlert({ acknowledged: '2026-03-01T09:55:00Z' }) },
|
||||
});
|
||||
const evt3 = makeEvent({
|
||||
id: 'evt-3',
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const key1 = buildAlertKey('evt-1', 'a1', fiveMinBefore);
|
||||
const result = getPendingAlerts([evt1, evt2, evt3], calendars, new Set([key1]), now);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].eventId).toBe('evt-3');
|
||||
});
|
||||
|
||||
it('skips alerts with unparseable offsets', () => {
|
||||
const event = makeEvent({
|
||||
alerts: {
|
||||
'a1': makeAlert({
|
||||
trigger: { '@type': 'OffsetTrigger', offset: 'invalid', relativeTo: 'start' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns calendarName as null when calendar not found', () => {
|
||||
const event = makeEvent({
|
||||
calendarIds: { 'missing': true },
|
||||
alerts: { 'a1': makeAlert() },
|
||||
});
|
||||
const calendars = [makeCalendar()];
|
||||
const now = fiveMinBefore + 1000;
|
||||
const result = getPendingAlerts([event], calendars, new Set(), now);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].calendarName).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarEventAlert,
|
||||
CalendarOffsetTrigger,
|
||||
CalendarAbsoluteTrigger,
|
||||
Calendar,
|
||||
} from '@/lib/jmap/types';
|
||||
|
||||
export interface PendingAlert {
|
||||
eventId: string;
|
||||
alertId: string;
|
||||
fireTimeMs: number;
|
||||
event: CalendarEvent;
|
||||
calendarName: string | null;
|
||||
}
|
||||
|
||||
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const DURATION_RE = /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
||||
|
||||
export function parseAlertOffset(offset: string): number | null {
|
||||
const match = DURATION_RE.exec(offset);
|
||||
if (!match) return null;
|
||||
|
||||
const negative = match[1] === '-';
|
||||
const days = parseInt(match[2] || '0', 10);
|
||||
const hours = parseInt(match[3] || '0', 10);
|
||||
const minutes = parseInt(match[4] || '0', 10);
|
||||
const seconds = parseInt(match[5] || '0', 10);
|
||||
|
||||
const ms = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
|
||||
return negative ? -ms : ms;
|
||||
}
|
||||
|
||||
export function computeFireTime(
|
||||
event: CalendarEvent,
|
||||
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger
|
||||
): number | null {
|
||||
if (trigger['@type'] === 'AbsoluteTrigger') {
|
||||
const t = new Date(trigger.when).getTime();
|
||||
return Number.isNaN(t) ? null : t;
|
||||
}
|
||||
|
||||
const offsetMs = parseAlertOffset(trigger.offset);
|
||||
if (offsetMs === null) return null;
|
||||
|
||||
let baseTime: number;
|
||||
if (trigger.relativeTo === 'end') {
|
||||
baseTime = event.utcEnd
|
||||
? new Date(event.utcEnd).getTime()
|
||||
: new Date(event.start).getTime();
|
||||
} else {
|
||||
baseTime = event.utcStart
|
||||
? new Date(event.utcStart).getTime()
|
||||
: new Date(event.start).getTime();
|
||||
}
|
||||
|
||||
if (Number.isNaN(baseTime)) return null;
|
||||
return baseTime + offsetMs;
|
||||
}
|
||||
|
||||
export function getEffectiveAlerts(
|
||||
event: CalendarEvent,
|
||||
calendars: Calendar[]
|
||||
): Record<string, CalendarEventAlert> | null {
|
||||
if (!event.useDefaultAlerts) {
|
||||
return event.alerts;
|
||||
}
|
||||
|
||||
const calendarId = Object.keys(event.calendarIds)[0];
|
||||
if (!calendarId) return null;
|
||||
|
||||
const calendar = calendars.find(c => c.id === calendarId);
|
||||
if (!calendar) return null;
|
||||
|
||||
if (event.showWithoutTime) {
|
||||
return calendar.defaultAlertsWithoutTime;
|
||||
}
|
||||
return calendar.defaultAlertsWithTime;
|
||||
}
|
||||
|
||||
export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: number): string {
|
||||
return `${eventId}:${alertId}:${fireTimeMs}`;
|
||||
}
|
||||
|
||||
export function getPendingAlerts(
|
||||
events: CalendarEvent[],
|
||||
calendars: Calendar[],
|
||||
acknowledgedKeys: Set<string>,
|
||||
now: number
|
||||
): PendingAlert[] {
|
||||
const pending: PendingAlert[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
const alerts = getEffectiveAlerts(event, calendars);
|
||||
if (!alerts) continue;
|
||||
|
||||
const calendar = calendars.find(c => c.id === Object.keys(event.calendarIds)[0]) ?? null;
|
||||
|
||||
for (const [alertId, alert] of Object.entries(alerts)) {
|
||||
if (alert.action !== 'display') continue;
|
||||
if (alert.acknowledged) continue;
|
||||
|
||||
const fireTimeMs = computeFireTime(event, alert.trigger);
|
||||
if (fireTimeMs === null) continue;
|
||||
if (fireTimeMs > now) continue;
|
||||
if (fireTimeMs <= now - STALE_THRESHOLD_MS) continue;
|
||||
|
||||
const key = buildAlertKey(event.id, alertId, fireTimeMs);
|
||||
if (acknowledgedKeys.has(key)) continue;
|
||||
|
||||
pending.push({
|
||||
eventId: event.id,
|
||||
alertId,
|
||||
fireTimeMs,
|
||||
event,
|
||||
calendarName: calendar?.name ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.value = 0.1;
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.15);
|
||||
oscillator.onended = () => audioContext.close();
|
||||
} catch (e) {
|
||||
debug.log('Could not play notification sound:', e);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user