0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 20:33:03 +08:00
discourse/app/views/static/service-worker.js.erb
Kris 0d45f20935
FIX: improve the reliability of chat sounds (#41373)
Currently chat notification sounds can fail to play when every Discourse
tab was hidden, replayed for the same message when switching to another
tab of the same site (each tab decided independently when the backlog
reached it), and piled up in never-interacted tabs and played on the
first click.

This change answers the requesting tab as primary when no tab is focused
or visible, dedupes alerts across tabs with a short-lived localStorage
containing `chat_message_id`, and drops a sound instead of queuing it
when the audio context is suspended.

It also allows `isPrimaryTab()` to ignore unrelated service worker
messages and fails open after 1s instead of hanging forever.

New tests are included to avoid regressions.
2026-07-02 18:52:41 -04:00

223 lines
7.5 KiB
Text
Vendored

'use strict';
// Plugins (and core features) can register handlers for notification action
// buttons by calling self.registerNotificationActionHandler(action, handler).
//
// The push payload may include:
// actions: a Web Notifications API "actions" array, attached to the
// notification verbatim (each entry: { action, title, type,
// placeholder, icon }).
// action_data: an arbitrary object made available to the handler at
// event.notification.data.actionData when the user activates
// an action button.
//
// When the user activates an action, the handler registered for that
// action name is invoked with the original notificationclick event.
self.notificationActionHandlers = {};
self.registerNotificationActionHandler = function (action, handler) {
self.notificationActionHandlers[action] = handler;
};
function buildNotificationOptions(payload) {
var options = {
body: payload.body,
icon: payload.icon,
badge: payload.badge,
data: {
url: payload.url,
baseUrl: payload.base_url,
actionData: payload.action_data || {},
},
tag: payload.tag,
};
if (Array.isArray(payload.actions) && payload.actions.length > 0) {
options.actions = payload.actions;
}
return options;
}
function focusOrOpenWindow(event) {
var url = event.notification.data.url;
var baseUrl = event.notification.data.baseUrl;
return clients.matchAll({ type: "window" }).then(function (clientList) {
// Skip nested frames (e.g. full-app embed iframes) — we want to
// focus/open a standalone Discourse window, not navigate an embed.
var topLevelClients = clientList.filter(function (client) {
return client.frameType !== "nested";
});
var reusedClientWindow = topLevelClients.some(function (client) {
if (client.url === baseUrl + url && "focus" in client) {
client.focus();
return true;
}
if ("postMessage" in client && "focus" in client) {
client.focus();
client.postMessage({ url: url });
return true;
}
return false;
});
if (!reusedClientWindow && clients.openWindow)
return clients.openWindow(baseUrl + url);
});
}
self.addEventListener('push', function(event) {
var payload = event.data.json();
event.waitUntil(
self.registration.showNotification(payload.title, buildNotificationOptions(payload))
);
});
self.addEventListener('notificationclick', function(event) {
// Android doesn't close the notification when you click on it
// See: http://crbug.com/463146
event.notification.close();
if (event.action) {
var handler = self.notificationActionHandlers[event.action];
if (handler) {
event.waitUntil(
Promise.resolve()
.then(function () { return handler(event); })
.catch(function (err) {
console.error("Push notification action handler failed", event.action, err);
return focusOrOpenWindow(event);
})
);
return;
}
}
event.waitUntil(focusOrOpenWindow(event));
});
self.addEventListener('pushsubscriptionchange', function(event) {
event.waitUntil(
Promise.all(
fetch('<%= Discourse.base_url %>/push_notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
"subscription[endpoint]": event.newSubscription.endpoint,
"subscription[keys][auth]": event.newSubscription.toJSON().keys.auth,
"subscription[keys][p256dh]": event.newSubscription.toJSON().keys.p256dh,
"send_confirmation": false
})
}),
fetch('<%= Discourse.base_url %>/push_notifications/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
"subscription[endpoint]": event.oldSubscription.endpoint,
"subscription[keys][auth]": event.oldSubscription.toJSON().keys.auth,
"subscription[keys][p256dh]": event.oldSubscription.toJSON().keys.p256dh
})
})
)
);
});
// Web Share Target (Level 2). The manifest registers a multipart/form-data
// POST share target at SHARE_TARGET_PATH. Browsers can't hand a multipart body
// to a single-page app directly, so we intercept the POST here, stash the
// shared title/text/url/files in the Cache API, and redirect to the same path
// as a GET navigation. The Ember `share-target` route then reads the payload
// back out of the cache and opens the "what do you want to do?" modal.
var SHARE_TARGET_PATH = '<%= Discourse.base_path %>/share-target';
var SHARE_TARGET_CACHE = 'discourse-share-target';
var SHARE_TARGET_KEY_PREFIX = '/__discourse_share_target__/';
function stashSharedContent(request) {
return request.formData().then(function (formData) {
return caches.open(SHARE_TARGET_CACHE).then(function (cache) {
var files = formData.getAll('files').filter(function (file) {
return file && typeof file !== 'string';
});
var fileMeta = [];
var puts = files.map(function (file, index) {
var key = SHARE_TARGET_KEY_PREFIX + 'file-' + index;
fileMeta.push({ key: key, name: file.name, type: file.type });
return cache.put(
new Request(key),
new Response(file, {
headers: {
'content-type': file.type || 'application/octet-stream',
'x-share-filename': encodeURIComponent(file.name || ('shared-file-' + index)),
},
})
);
});
var meta = {
title: formData.get('title') || '',
text: formData.get('text') || '',
url: formData.get('url') || '',
files: fileMeta,
};
puts.push(
cache.put(
new Request(SHARE_TARGET_KEY_PREFIX + 'meta'),
new Response(JSON.stringify(meta), {
headers: { 'content-type': 'application/json' },
})
)
);
return Promise.all(puts);
});
});
}
self.addEventListener('fetch', function (event) {
var url = new URL(event.request.url);
if (event.request.method === 'POST' && url.pathname === SHARE_TARGET_PATH) {
event.respondWith(
stashSharedContent(event.request)
.catch(function (err) {
console.error('Failed to stash shared content', err);
})
.then(function () {
// 303 forces the follow-up navigation to be a GET.
return Response.redirect(SHARE_TARGET_PATH, 303);
})
);
}
});
self.addEventListener('message', function(event) {
if (event.data?.action !== "primaryTab" || !event.source) {
return;
}
event.waitUntil(
// includeUncontrolled so first-visit and hard-reloaded tabs take part in
// the election instead of silently failing open client-side
self.clients.matchAll({ includeUncontrolled: true }).then(function(clients) {
const activeClient =
clients.find(client => client.focused) ||
clients.find(client => client.visibilityState === "visible");
// when every tab is hidden (user is in another app or on another site),
// the requester acts as primary — unlike an elected hidden tab it is
// provably running, and simultaneous self-elections are deduped
event.source.postMessage({
primaryTab: activeClient ? event.source.id === activeClient.id : true
});
})
);
});
<% DiscoursePluginRegistry.service_workers.each do |js| %>
<%=raw "#{File.read(js)}" %>
<% end %>