From 06df51ca08a1e79fe5e008a4a30adf03f924be95 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 30 Sep 2026 17:01:34 +0800 Subject: [PATCH] Prime message audio and silence duplicate OS notification sound --- web/src/lib/notifications/push.ts | 6 +- web/src/lib/notifications/sound.ts | 23 ++++++ .../brand-and-notification-sound.test.mjs | 80 ++++++++++++++++--- 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/web/src/lib/notifications/push.ts b/web/src/lib/notifications/push.ts index 43a3c2ec..6fccc6d9 100644 --- a/web/src/lib/notifications/push.ts +++ b/web/src/lib/notifications/push.ts @@ -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: * 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. */ -function showNotification(topicName: string, title: string, body: string) { +function showNotification(topicName: string, title: string, body: string, silent = false) { let notif: Notification; 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 { // 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 @@ -116,7 +116,7 @@ function fireNotification(topicName: string, msg: StoredMessage, contact?: Conta const title = contact?.public?.fn || topicName; const myUserId = useSessionStore.getState().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 diff --git a/web/src/lib/notifications/sound.ts b/web/src/lib/notifications/sound.ts index 89ebb9c4..779ab134 100644 --- a/web/src/lib/notifications/sound.ts +++ b/web/src/lib/notifications/sound.ts @@ -1,4 +1,27 @@ let audio: HTMLAudioElement | null = null; +let priming = false; + +async function primeSound(): Promise { + 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 { try { diff --git a/web/tests/brand-and-notification-sound.test.mjs b/web/tests/brand-and-notification-sound.test.mjs index ea73f26d..03b807c4 100644 --- a/web/tests/brand-and-notification-sound.test.mjs +++ b/web/tests/brand-and-notification-sound.test.mjs @@ -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); });