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
+3 -3
View File
@@ -95,10 +95,10 @@ export function notifyIncomingMessage(topicName: string, msg: StoredMessage, con
/** Shared by both real call sites below -- a new message, or being newly added to a group. `tag: /** Shared by both real call sites below -- a new message, or being newly added to a group. `tag:
* topicName` makes a second notification from the same conversation REPLACE the first in the OS * topicName` makes a second notification from the same conversation REPLACE the first in the OS
* notification tray instead of stacking, matching every mainstream IM web client. */ * notification tray instead of stacking, matching every mainstream IM web client. */
function showNotification(topicName: string, title: string, body: string) { function showNotification(topicName: string, title: string, body: string, silent = false) {
let notif: Notification; let notif: Notification;
try { try {
notif = new Notification(title, { body, tag: topicName, icon: '/app-icon.png' }); notif = new Notification(title, { body, tag: topicName, icon: '/app-icon.png', ...(silent ? { silent: true } : {}) });
} catch { } catch {
// Some browsers/contexts throw constructing Notification even with permission granted (e.g. an // Some browsers/contexts throw constructing Notification even with permission granted (e.g. an
// OS-level notification setting disabled after the fact) -- never let this crash the message // OS-level notification setting disabled after the fact) -- never let this crash the message
@@ -116,7 +116,7 @@ function fireNotification(topicName: string, msg: StoredMessage, contact?: Conta
const title = contact?.public?.fn || topicName; const title = contact?.public?.fn || topicName;
const myUserId = useSessionStore.getState().myUserId; const myUserId = useSessionStore.getState().myUserId;
const body = formatMessagePreview(msg, myUserId) || '发来一条新消息'; const body = formatMessagePreview(msg, myUserId) || '发来一条新消息';
showNotification(topicName, title, body); showNotification(topicName, title, body, true);
} }
/** "被拉入群时title的消息没有提示" -- topics.ts's reconcileMeSubs() is the only thing that can even /** "被拉入群时title的消息没有提示" -- topics.ts's reconcileMeSubs() is the only thing that can even
+23
View File
@@ -1,4 +1,27 @@
let audio: HTMLAudioElement | null = null; let audio: HTMLAudioElement | null = null;
let priming = false;
async function primeSound(): Promise<void> {
if (priming) return;
priming = true;
try {
audio ??= new Audio('/message-notification.mp3');
audio.muted = true;
await audio.play();
audio.pause();
audio.currentTime = 0;
audio.muted = false;
window.removeEventListener('pointerdown', primeSound);
window.removeEventListener('keydown', primeSound);
} catch {
try { if (audio) audio.muted = false; } catch { /* Audio may be unavailable. */ }
} finally {
priming = false;
}
}
window.addEventListener('pointerdown', primeSound);
window.addEventListener('keydown', primeSound);
export function playMessageNotificationSound(): void { export function playMessageNotificationSound(): void {
try { try {
+69 -11
View File
@@ -14,8 +14,8 @@ window.localStorage = localStorage;
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const jiti = createJiti(import.meta.url, { alias: { '@': `${root}src` } }); 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'); 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 () => { 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'); 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 () => { 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'); assert.equal(typeof sound.playMessageNotificationSound, 'function');
const played = []; const played = [];
globalThis.Audio = class { 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();
sound.playMessageNotificationSound(); sound.playMessageNotificationSound();
assert.equal(played.length, 1); assert.equal(played.length, 1);
soundAudio = played[0];
assert.equal(played[0].src, '/message-notification.mp3'); assert.equal(played[0].src, '/message-notification.mp3');
assert.equal(played[0].currentTime, 0); assert.equal(played[0].currentTime, 0);
played[0].play = () => { throw new Error('blocked'); }; 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)); 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 () => { 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'); const { notifyIncomingMessage } = await jiti.import('../src/lib/notifications/push.ts');
assert.equal(typeof notifyIncomingMessage, 'function'); assert.equal(typeof notifyIncomingMessage, 'function');
const { useSessionStore } = await jiti.import('../src/stores/session.ts'); const { useSessionStore } = await jiti.import('../src/stores/session.ts');
const { setActiveConversationTopic } = await jiti.import('../src/lib/tinode/topics.ts'); const { setActiveConversationTopic } = await jiti.import('../src/lib/tinode/topics.ts');
useSessionStore.setState({ myUserId: 'me' }); useSessionStore.setState({ myUserId: 'me' });
let plays = 0; const notifications = [];
soundAudio.play = () => { plays++; return Promise.resolve(); };
let notifications = 0;
globalThis.Notification = class { globalThis.Notification = class {
static permission = 'default'; static permission = 'default';
constructor() { notifications++; } constructor(title, options) { notifications.push({ title, options }); }
}; };
const contact = { topic: 'p2p-test', public: { fn: '朋友' }, private: {} }; const contact = { topic: 'p2p-test', public: { fn: '朋友' }, private: {} };
notifyIncomingMessage('p2p-test', { from: 'me' }, contact); 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); assert.equal(plays, 0);
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 1); assert.equal(plays, 1);
assert.equal(notifications, 0); assert.equal(notifications.length, 0);
delete globalThis.Notification; delete globalThis.Notification;
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 2); assert.equal(plays, 2);
assert.equal(notifications, 0); assert.equal(notifications.length, 0);
globalThis.Notification = class { globalThis.Notification = class {
static permission = 'granted'; static permission = 'granted';
constructor() { notifications++; } constructor(title, options) { notifications.push({ title, options }); }
}; };
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 3); assert.equal(plays, 3);
assert.equal(notifications, 1); assert.equal(notifications.length, 1);
assert.equal(notifications[0].options.silent, true);
}); });