From 7bcd46b87d811ca0fae38292aea03a5eaec06450 Mon Sep 17 00:00:00 2001 From: David Battersby Date: Tue, 29 Oct 2024 13:15:53 +0400 Subject: [PATCH] DEV: use service worker for chat sound (#29388) This change makes use of service workers to determine if we should play chat sounds in the current browser tab. Since users can have multiple tabs open, we currently attempt to play sound across all active tabs. With this change we iterate over all clients and check if client.focused is true (ie. the current tab/window we have open), if so we allow playing the audio in the current tab and for all other hidden tabs/windows we return false. --------- Co-authored-by: Bianca Nenciu --- .../discourse/app/lib/utilities.js | 16 +++++++ .../discourse/app/services/capabilities.js | 10 +++++ app/assets/javascripts/service-worker.js.erb | 17 ++++++- .../discourse/initializers/chat-audio.js | 22 ++++++---- .../javascripts/unit/lib/chat-audio-test.js | 44 ++++++++++++------- 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index 13f5ef07039..8443ba25d4a 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -769,3 +769,19 @@ export function cleanNullQueryParams(params) { export function getElement(node) { return node.nodeType === Node.TEXT_NODE ? node.parentElement : node; } + +export function isPrimaryTab() { + return new Promise((resolve) => { + if (capabilities.supportsServiceWorker) { + navigator.serviceWorker.addEventListener("message", (event) => { + resolve(event.data.primaryTab); + }); + + navigator.serviceWorker.ready.then((registration) => { + registration.active.postMessage({ action: "primaryTab" }); + }); + } else { + resolve(true); + } + }); +} diff --git a/app/assets/javascripts/discourse/app/services/capabilities.js b/app/assets/javascripts/discourse/app/services/capabilities.js index 565474d99e2..37b40bd8d2f 100644 --- a/app/assets/javascripts/discourse/app/services/capabilities.js +++ b/app/assets/javascripts/discourse/app/services/capabilities.js @@ -43,6 +43,16 @@ class Capabilities { !("userActivation" in navigator) || navigator.userActivation.hasBeenActive ); } + + get supportsServiceWorker() { + return ( + "serviceWorker" in navigator && + typeof ServiceWorkerRegistration !== "undefined" && + !this.isAppWebview && + navigator.serviceWorker.controller && + navigator.serviceWorker.controller.state === "activated" + ); + } } export const capabilities = new Capabilities(); diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index 6289572dcc3..3431658c23c 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -99,7 +99,6 @@ self.addEventListener('notificationclick', function(event) { } }); - self.addEventListener('pushsubscriptionchange', function(event) { event.waitUntil( Promise.all( @@ -126,6 +125,22 @@ self.addEventListener('pushsubscriptionchange', function(event) { ); }); +self.addEventListener('message', function(event) { + if (event.data?.action !== "primaryTab") { + return; + } + + event.waitUntil( + self.clients.matchAll().then(function(clients) { + clients.forEach(function(client) { + client.postMessage({ + primaryTab: client.focused + }); + }); + }) + ); + }); + <% DiscoursePluginRegistry.service_workers.each do |js| %> <%=raw "#{File.read(js)}" %> <% end %> diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js index cd64b85c099..cc3bf623c2c 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js @@ -1,4 +1,5 @@ import { withPluginApi } from "discourse/lib/plugin-api"; +import { isPrimaryTab } from "discourse/lib/utilities"; import { INDICATOR_PREFERENCES } from "discourse/plugins/chat/discourse/lib/chat-constants"; const MENTION = 29; @@ -15,6 +16,10 @@ export default { return; } + this.canPlaySound = async () => { + return await isPrimaryTab(); + }; + withPluginApi("0.12.1", (api) => { api.registerDesktopNotificationHandler((data, siteSettings, user) => { const indicatorType = user.user_option.chat_header_indicator_preference; @@ -24,10 +29,7 @@ export default { return; } - if ( - !user.user_option.chat_sound || - indicatorType === INDICATOR_PREFERENCES.never - ) { + if (!user.chat_sound || indicatorType === INDICATOR_PREFERENCES.never) { return; } @@ -47,10 +49,14 @@ export default { } if (CHAT_NOTIFICATION_TYPES.includes(data.notification_type)) { - const chatAudioManager = container.lookup( - "service:chat-audio-manager" - ); - chatAudioManager.play(user.chat_sound); + this.canPlaySound().then((success) => { + if (success) { + const chatAudioManager = container.lookup( + "service:chat-audio-manager" + ); + chatAudioManager.play(user.chat_sound); + } + }); } }); }); diff --git a/plugins/chat/test/javascripts/unit/lib/chat-audio-test.js b/plugins/chat/test/javascripts/unit/lib/chat-audio-test.js index f51ade2eeaf..301b16b4c81 100644 --- a/plugins/chat/test/javascripts/unit/lib/chat-audio-test.js +++ b/plugins/chat/test/javascripts/unit/lib/chat-audio-test.js @@ -19,14 +19,19 @@ module("Discourse Chat | Unit | chat-audio", function (hooks) { this.siteSettings = getOwner(this).lookup("service:site-settings"); this.siteSettings.chat_enabled = true; + this.currentUser.chat_sound = "ding"; this.currentUser.user_option.has_chat_enabled = true; - this.currentUser.user_option.chat_sound = "ding"; this.currentUser.user_option.chat_header_indicator_preference = "all_new"; withPluginApi("0.12.1", async (api) => { this.stub = sinon.spy(api, "registerDesktopNotificationHandler"); chatAudioInitializer.initialize(getOwner(this)); + // stub the service worker response + sinon + .stub(chatAudioInitializer, "canPlaySound") + .returns(Promise.resolve(true)); + this.notificationHandler = this.stub.getCall(0).callback; this.playStub = sinon.stub(chatAudioManager, "play"); @@ -43,58 +48,65 @@ module("Discourse Chat | Unit | chat-audio", function (hooks) { assert.ok(this.stub.calledOnce); }); - test("it plays chat sound", function (assert) { - this.handleNotification(); + test("it plays chat sound", async function (assert) { + await this.handleNotification(); assert.ok(this.playStub.calledOnce); }); - test("it skips chat sound for user in DND mode", function (assert) { + test("it skips chat sound for user in DND mode", async function (assert) { this.currentUser.isInDoNotDisturb = () => true; - this.handleNotification(); + await this.handleNotification(); assert.ok(this.playStub.notCalled); }); - test("it skips chat sound for user with no chat sound set", function (assert) { - this.currentUser.user_option.chat_sound = null; - this.handleNotification(); + test("it skips chat sound for user with no chat sound set", async function (assert) { + this.currentUser.chat_sound = null; + await this.handleNotification(); assert.ok(this.playStub.notCalled); }); - test("it plays a chat sound for mentions", function (assert) { + test("it plays a chat sound for mentions", async function (assert) { this.currentUser.user_option.chat_header_indicator_preference = "only_mentions"; - this.handleNotification({ notification_type: 29 }); + await this.handleNotification({ notification_type: 29 }); assert.ok(this.playStub.calledOnce); }); - test("it skips chat sound for non-mentions", function (assert) { + test("it skips chat sound for non-mentions", async function (assert) { this.currentUser.user_option.chat_header_indicator_preference = "only_mentions"; - this.handleNotification(); + await this.handleNotification(); assert.ok(this.playStub.notCalled); }); - test("it plays a chat sound for DMs", function (assert) { + test("it plays a chat sound for DMs", async function (assert) { this.currentUser.user_option.chat_header_indicator_preference = "dm_and_mentions"; - this.handleNotification({ is_direct_message_channel: true }); + await this.handleNotification({ is_direct_message_channel: true }); assert.ok(this.playStub.calledOnce); }); - test("it skips chat sound for non-DM messages", function (assert) { + test("it skips chat sound for non-DM messages", async function (assert) { this.currentUser.user_option.chat_header_indicator_preference = "dm_and_mentions"; - this.handleNotification({ is_direct_message_channel: false }); + await this.handleNotification({ is_direct_message_channel: false }); + + assert.ok(this.playStub.notCalled); + }); + + test("it skips chat sound when service worker returns false", async function (assert) { + chatAudioInitializer.canPlaySound.returns(Promise.resolve(false)); + await this.handleNotification(); assert.ok(this.playStub.notCalled); });