Prime message audio and silence duplicate OS notification sound

This commit is contained in:
Your Name
2026-09-30 17:01:34 +08:00
parent 1c17165c86
commit 06df51ca08
3 changed files with 95 additions and 14 deletions
+69 -11
View File
@@ -14,8 +14,8 @@ window.localStorage = localStorage;
const root = fileURLToPath(new URL('../', import.meta.url));
const jiti = createJiti(import.meta.url, { alias: { '@': `${root}src` } });
const freshSound = () => createJiti(import.meta.url, { alias: { '@': `${root}src` }, moduleCache: false }).import('../src/lib/notifications/sound.ts');
const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
let soundAudio;
test('display brand changes while the Tinode client identifier stays MeetingIM', async () => {
const { BASE_APP_NAME, APP_NAME } = await jiti.import('../src/lib/config.ts');
@@ -52,7 +52,7 @@ test('official image and supplied sound are copied byte for byte and used for ic
});
test('sound is lazy, rewinds on repeat, and ignores sync and async playback failures', async () => {
const sound = await jiti.import('../src/lib/notifications/sound.ts');
const sound = await freshSound();
assert.equal(typeof sound.playMessageNotificationSound, 'function');
const played = [];
globalThis.Audio = class {
@@ -63,7 +63,6 @@ test('sound is lazy, rewinds on repeat, and ignores sync and async playback fail
sound.playMessageNotificationSound();
sound.playMessageNotificationSound();
assert.equal(played.length, 1);
soundAudio = played[0];
assert.equal(played[0].src, '/message-notification.mp3');
assert.equal(played[0].currentTime, 0);
played[0].play = () => { throw new Error('blocked'); };
@@ -73,18 +72,76 @@ test('sound is lazy, rewinds on repeat, and ignores sync and async playback fail
await new Promise((resolve) => setImmediate(resolve));
});
test('first gesture silently primes one audio element for later message playback', async () => {
const handlers = new Map();
window.addEventListener = (type, handler) => handlers.set(type, handler);
window.removeEventListener = (type) => handlers.delete(type);
const instances = [];
globalThis.Audio = class {
constructor(src) { this.src = src; this.currentTime = 8; this.muted = false; this.plays = []; this.pauses = 0; instances.push(this); }
play() { this.plays.push(this.muted); return Promise.resolve(); }
pause() { this.pauses++; }
};
const sound = await freshSound();
assert.equal(instances.length, 0);
assert.equal(typeof handlers.get('pointerdown'), 'function');
assert.equal(typeof handlers.get('keydown'), 'function');
const prime = handlers.get('pointerdown')();
assert.equal(instances.length, 1);
assert.equal(instances[0].muted, true);
assert.deepEqual(instances[0].plays, [true]);
await prime;
assert.equal(instances[0].pauses, 1);
assert.equal(instances[0].currentTime, 0);
assert.equal(instances[0].muted, false);
assert.equal(handlers.size, 0);
sound.playMessageNotificationSound();
assert.equal(instances.length, 1);
assert.deepEqual(instances[0].plays, [true, false]);
});
test('failed gesture prime unmutes and retries on the next gesture', async () => {
const handlers = new Map();
window.addEventListener = (type, handler) => handlers.set(type, handler);
window.removeEventListener = (type) => handlers.delete(type);
let attempts = 0;
let pauses = 0;
let instance;
globalThis.Audio = class {
constructor() { instance = this; }
currentTime = 4;
muted = false;
play() { return ++attempts === 1 ? Promise.reject(new Error('blocked')) : Promise.resolve(); }
pause() { pauses++; }
};
const sound = await freshSound();
assert.equal(typeof handlers.get('pointerdown'), 'function');
await handlers.get('pointerdown')();
assert.equal(attempts, 1);
assert.equal(instance.muted, false);
assert.equal(handlers.size, 2);
await handlers.get('keydown')();
assert.equal(attempts, 2);
assert.equal(pauses, 1);
assert.equal(instance.muted, false);
assert.equal(instance.currentTime, 0);
assert.equal(handlers.size, 0);
sound.playMessageNotificationSound();
assert.equal(attempts, 3);
});
test('incoming alerts gate own, visible and muted messages, but sound needs no notification permission', async () => {
let plays = 0;
globalThis.Audio = class { currentTime = 0; play() { plays++; return Promise.resolve(); } };
const { notifyIncomingMessage } = await jiti.import('../src/lib/notifications/push.ts');
assert.equal(typeof notifyIncomingMessage, 'function');
const { useSessionStore } = await jiti.import('../src/stores/session.ts');
const { setActiveConversationTopic } = await jiti.import('../src/lib/tinode/topics.ts');
useSessionStore.setState({ myUserId: 'me' });
let plays = 0;
soundAudio.play = () => { plays++; return Promise.resolve(); };
let notifications = 0;
const notifications = [];
globalThis.Notification = class {
static permission = 'default';
constructor() { notifications++; }
constructor(title, options) { notifications.push({ title, options }); }
};
const contact = { topic: 'p2p-test', public: { fn: '朋友' }, private: {} };
notifyIncomingMessage('p2p-test', { from: 'me' }, contact);
@@ -97,16 +154,17 @@ test('incoming alerts gate own, visible and muted messages, but sound needs no n
assert.equal(plays, 0);
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 1);
assert.equal(notifications, 0);
assert.equal(notifications.length, 0);
delete globalThis.Notification;
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 2);
assert.equal(notifications, 0);
assert.equal(notifications.length, 0);
globalThis.Notification = class {
static permission = 'granted';
constructor() { notifications++; }
constructor(title, options) { notifications.push({ title, options }); }
};
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 3);
assert.equal(notifications, 1);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].options.silent, true);
});