Prime message audio and silence duplicate OS notification sound
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user