Web Push Notifications
This commit is contained in:
parent
a99ea14dc0
commit
0ac3ba28e1
@ -10,7 +10,7 @@ Modern web IRC client designed for self-hosting.
|
||||
|
||||
## Overview
|
||||
|
||||
* **Modern features brought to IRC.** Link previews, new message markers, ~~push notifications~~ [*(soon!)*](https://github.com/thelounge/lounge/pull/1124), and more bring IRC to the 21st century.
|
||||
* **Modern features brought to IRC.** Push notifications, link previews, new message markers, and more bring IRC to the 21st century.
|
||||
* **Always connected.** Remains connected to IRC servers while you are offline.
|
||||
* **Cross platform.** It doesn't matter what OS you use, it just works wherever Node.js runs.
|
||||
* **Responsive interface.** The client works smoothly on every desktop, smartphone and tablet.
|
||||
|
@ -688,7 +688,8 @@ kbd {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#windows label {
|
||||
#windows label,
|
||||
#settings .error {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
@ -293,20 +293,36 @@
|
||||
</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#unless public}}
|
||||
<div class="col-sm-12">
|
||||
<h2>Notifications</h2>
|
||||
<h2>Push Notifications</h2>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn" id="pushNotifications" disabled data-text-alternate="Unsubscribe from push notifications">Subscribe to push notifications</button>
|
||||
<div class="error" id="pushNotificationsHttps">
|
||||
<strong>Warning</strong>:
|
||||
Push notifications are only supported over HTTPS connections.
|
||||
</div>
|
||||
<div class="error" id="pushNotificationsUnsupported">
|
||||
<strong>Warning</strong>:
|
||||
<span>Push notifications are not supported by your browser.</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div class="col-sm-12">
|
||||
<h2>Browser Notifications</h2>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<label class="opt">
|
||||
<input id="desktopNotifications" type="checkbox" name="desktopNotifications">
|
||||
Enable desktop notifications<br>
|
||||
Enable browser notifications<br>
|
||||
<div class="error" id="warnUnsupportedDesktopNotifications">
|
||||
<strong>Warning</strong>:
|
||||
Desktop notifications are not supported by your browser.
|
||||
Notifications are not supported by your browser.
|
||||
</div>
|
||||
<div class="error" id="warnBlockedDesktopNotifications">
|
||||
<strong>Warning</strong>:
|
||||
Desktop notifications are blocked by your browser.
|
||||
Notifications are blocked by your browser.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
@ -22,6 +22,7 @@ require("./socket-events");
|
||||
const constants = require("./constants");
|
||||
const storage = require("./localStorage");
|
||||
const utils = require("./utils");
|
||||
require("./webpush");
|
||||
|
||||
$(function() {
|
||||
var sidebar = $("#sidebar, #footer");
|
||||
|
@ -3,6 +3,7 @@
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const webpush = require("../webpush");
|
||||
const sidebar = $("#sidebar");
|
||||
const storage = require("../localStorage");
|
||||
|
||||
@ -21,6 +22,8 @@ socket.on("init", function(data) {
|
||||
storage.set("token", data.token);
|
||||
}
|
||||
|
||||
webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
|
||||
|
||||
$("body").removeClass("signed-out");
|
||||
$("#loading").remove();
|
||||
$("#sign-in").remove();
|
||||
|
127
client/js/webpush.js
Normal file
127
client/js/webpush.js
Normal file
@ -0,0 +1,127 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const storage = require("./localStorage");
|
||||
const socket = require("./socket");
|
||||
|
||||
const pushNotificationsButton = $("#pushNotifications");
|
||||
let clientSubscribed = null;
|
||||
let applicationServerKey;
|
||||
|
||||
module.exports.configurePushNotifications = (subscribedOnServer, key) => {
|
||||
applicationServerKey = key;
|
||||
|
||||
// If client has push registration but the server knows nothing about it,
|
||||
// this subscription is broken and client has to register again
|
||||
if (clientSubscribed === true && subscribedOnServer === false) {
|
||||
pushNotificationsButton.attr("disabled", true);
|
||||
|
||||
navigator.serviceWorker.register("service-worker.js")
|
||||
.then((registration) => registration.pushManager.getSubscription())
|
||||
.then((subscription) => subscription && subscription.unsubscribe())
|
||||
.then((successful) => {
|
||||
if (successful) {
|
||||
alternatePushButton().removeAttr("disabled");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isAllowedServiceWorkersHost()) {
|
||||
$("#pushNotificationsHttps").hide();
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("service-worker.js").then((registration) => {
|
||||
if (!registration.pushManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
return registration.pushManager.getSubscription().then((subscription) => {
|
||||
$("#pushNotificationsUnsupported").hide();
|
||||
|
||||
pushNotificationsButton
|
||||
.removeAttr("disabled")
|
||||
.on("click", onPushButton);
|
||||
|
||||
clientSubscribed = !!subscription;
|
||||
|
||||
if (clientSubscribed) {
|
||||
alternatePushButton();
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
$("#pushNotificationsUnsupported span").text(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onPushButton() {
|
||||
pushNotificationsButton.attr("disabled", true);
|
||||
|
||||
navigator.serviceWorker.register("service-worker.js").then((registration) => {
|
||||
registration.pushManager.getSubscription().then((existingSubscription) => {
|
||||
if (existingSubscription) {
|
||||
socket.emit("push:unregister");
|
||||
|
||||
return existingSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
return registration.pushManager.subscribe({
|
||||
applicationServerKey: urlBase64ToUint8Array(applicationServerKey),
|
||||
userVisibleOnly: true
|
||||
}).then((subscription) => {
|
||||
const rawKey = subscription.getKey ? subscription.getKey("p256dh") : "";
|
||||
const key = rawKey ? window.btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "";
|
||||
const rawAuthSecret = subscription.getKey ? subscription.getKey("auth") : "";
|
||||
const authSecret = rawAuthSecret ? window.btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : "";
|
||||
|
||||
socket.emit("push:register", {
|
||||
token: storage.get("token"),
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: key,
|
||||
auth: authSecret
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
}).then((successful) => {
|
||||
if (successful) {
|
||||
alternatePushButton().removeAttr("disabled");
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
$("#pushNotificationsUnsupported span").text(err).show();
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function alternatePushButton() {
|
||||
const text = pushNotificationsButton.text();
|
||||
|
||||
return pushNotificationsButton
|
||||
.text(pushNotificationsButton.data("text-alternate"))
|
||||
.data("text-alternate", text);
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function isAllowedServiceWorkersHost() {
|
||||
return location.protocol === "https:" || location.hostname === "localhost" || location.hostname === "127.0.0.1";
|
||||
}
|
41
client/service-worker.js
Normal file
41
client/service-worker.js
Normal file
@ -0,0 +1,41 @@
|
||||
// The Lounge - https://github.com/thelounge/lounge
|
||||
/* global clients */
|
||||
"use strict";
|
||||
|
||||
self.addEventListener("push", function(event) {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = event.data.json();
|
||||
|
||||
if (payload.type === "notification") {
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, {
|
||||
badge: "img/logo-64.png",
|
||||
icon: "img/touch-icon-192x192.png",
|
||||
body: payload.body,
|
||||
timestamp: payload.timestamp,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", function(event) {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(clients.matchAll({
|
||||
type: "window"
|
||||
}).then(function(clientList) {
|
||||
for (var i = 0; i < clientList.length; i++) {
|
||||
var client = clientList[i];
|
||||
if ("focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(".");
|
||||
}
|
||||
}));
|
||||
});
|
@ -58,7 +58,8 @@
|
||||
"socket.io": "1.7.4",
|
||||
"spdy": "3.4.7",
|
||||
"ua-parser-js": "0.7.14",
|
||||
"urijs": "1.18.12"
|
||||
"urijs": "1.18.12",
|
||||
"web-push": "3.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "6.26.0",
|
||||
|
@ -94,11 +94,21 @@ function Client(manager, name, config) {
|
||||
client.config.sessions = {};
|
||||
}
|
||||
|
||||
_.forOwn(client.config.sessions, (session) => {
|
||||
if (session.pushSubscription) {
|
||||
this.registerPushSubscription(session, session.pushSubscription, true);
|
||||
}
|
||||
});
|
||||
|
||||
if (client.name) {
|
||||
log.info(`User ${colors.bold(client.name)} loaded`);
|
||||
}
|
||||
}
|
||||
|
||||
Client.prototype.isRegistered = function() {
|
||||
return this.name.length > 0;
|
||||
};
|
||||
|
||||
Client.prototype.emit = function(event, data) {
|
||||
if (this.sockets !== null) {
|
||||
this.sockets.in(this.id).emit(event, data);
|
||||
@ -310,11 +320,11 @@ Client.prototype.updateSession = function(token, ip, request) {
|
||||
friendlyAgent += ` on ${agent.os.name} ${agent.os.version}`;
|
||||
}
|
||||
|
||||
client.config.sessions[token] = {
|
||||
client.config.sessions[token] = _.assign({
|
||||
lastUse: Date.now(),
|
||||
ip: ip,
|
||||
agent: friendlyAgent,
|
||||
};
|
||||
}, client.config.sessions[token]);
|
||||
};
|
||||
|
||||
Client.prototype.setPassword = function(hash, callback) {
|
||||
@ -410,7 +420,7 @@ Client.prototype.more = function(data) {
|
||||
Client.prototype.open = function(socketId, target) {
|
||||
// Opening a window like settings
|
||||
if (target === null) {
|
||||
this.attachedClients[socketId] = -1;
|
||||
this.attachedClients[socketId].openChannel = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -423,7 +433,7 @@ Client.prototype.open = function(socketId, target) {
|
||||
target.chan.unread = 0;
|
||||
target.chan.highlight = false;
|
||||
|
||||
this.attachedClients[socketId] = target.chan.id;
|
||||
this.attachedClients[socketId].openChannel = target.chan.id;
|
||||
this.lastActiveChannel = target.chan.id;
|
||||
|
||||
this.emit("open", target.chan.id);
|
||||
@ -493,7 +503,7 @@ Client.prototype.quit = function() {
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.clientAttach = function(socketId) {
|
||||
Client.prototype.clientAttach = function(socketId, token) {
|
||||
var client = this;
|
||||
var save = false;
|
||||
|
||||
@ -507,7 +517,10 @@ Client.prototype.clientAttach = function(socketId) {
|
||||
});
|
||||
}
|
||||
|
||||
client.attachedClients[socketId] = client.lastActiveChannel;
|
||||
client.attachedClients[socketId] = {
|
||||
token: token,
|
||||
openChannel: client.lastActiveChannel
|
||||
};
|
||||
|
||||
// Update old networks to store ip and hostmask
|
||||
client.networks.forEach((network) => {
|
||||
@ -547,6 +560,40 @@ Client.prototype.clientDetach = function(socketId) {
|
||||
}
|
||||
};
|
||||
|
||||
Client.prototype.registerPushSubscription = function(session, subscription, noSave) {
|
||||
if (!_.isPlainObject(subscription) || !_.isPlainObject(subscription.keys)
|
||||
|| typeof subscription.endpoint !== "string" || !/^https?:\/\//.test(subscription.endpoint)
|
||||
|| typeof subscription.keys.p256dh !== "string" || typeof subscription.keys.auth !== "string") {
|
||||
session.pushSubscription = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth
|
||||
}
|
||||
};
|
||||
|
||||
session.pushSubscription = data;
|
||||
|
||||
if (!noSave) {
|
||||
this.manager.updateUser(this.name, {
|
||||
sessions: this.config.sessions
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
Client.prototype.unregisterPushSubscription = function(token) {
|
||||
this.config.sessions[token].pushSubscription = null;
|
||||
this.manager.updateUser(this.name, {
|
||||
sessions: this.config.sessions
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.save = _.debounce(function SaveClient() {
|
||||
if (Helper.config.public) {
|
||||
return;
|
||||
|
@ -5,6 +5,7 @@ var colors = require("colors/safe");
|
||||
var fs = require("fs");
|
||||
var Client = require("./client");
|
||||
var Helper = require("./helper");
|
||||
const WebPush = require("./plugins/webpush");
|
||||
|
||||
module.exports = ClientManager;
|
||||
|
||||
@ -15,6 +16,7 @@ function ClientManager() {
|
||||
ClientManager.prototype.init = function(identHandler, sockets) {
|
||||
this.sockets = sockets;
|
||||
this.identHandler = identHandler;
|
||||
this.webPush = new WebPush();
|
||||
|
||||
if (!Helper.config.public) {
|
||||
if ("autoload" in Helper.config) {
|
||||
|
@ -19,6 +19,7 @@ var Helper = {
|
||||
getVersion: getVersion,
|
||||
getGitCommit: getGitCommit,
|
||||
ip2hex: ip2hex,
|
||||
cleanIrcMessage: cleanIrcMessage,
|
||||
|
||||
password: {
|
||||
hash: passwordHash,
|
||||
@ -121,6 +122,11 @@ function expandHome(shortenedPath) {
|
||||
return path.resolve(shortenedPath.replace(/^~($|\/|\\)/, home + "$1"));
|
||||
}
|
||||
|
||||
function cleanIrcMessage(message) {
|
||||
// TODO: This does not strip hex based colours
|
||||
return message.replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, "");
|
||||
}
|
||||
|
||||
function passwordRequiresUpdate(password) {
|
||||
return bcrypt.getRounds(password) !== 11;
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) {
|
||||
};
|
||||
|
||||
// If this channel is open in any of the clients, do not increase unread counter
|
||||
var isOpen = _.includes(client.attachedClients, this.id);
|
||||
const isOpen = _.find(client.attachedClients, {openChannel: this.id}) !== undefined;
|
||||
|
||||
if ((increasesUnread || msg.highlight) && !isOpen) {
|
||||
obj.unread = ++this.unread;
|
||||
|
@ -16,7 +16,7 @@ module.exports = function(client, chan, msg) {
|
||||
}
|
||||
|
||||
// Remove all IRC formatting characters before searching for links
|
||||
const cleanText = msg.text.replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, "");
|
||||
const cleanText = Helper.cleanIrcMessage(msg.text);
|
||||
|
||||
// We will only try to prefetch http(s) links
|
||||
const links = findLinks(cleanText).filter((w) => /^https?:\/\//.test(w.link));
|
||||
|
@ -3,6 +3,7 @@
|
||||
const Chan = require("../../models/chan");
|
||||
const Msg = require("../../models/msg");
|
||||
const LinkPrefetch = require("./link");
|
||||
const Helper = require("../../helper");
|
||||
|
||||
module.exports = function(irc, network) {
|
||||
var client = this;
|
||||
@ -102,5 +103,24 @@ module.exports = function(irc, network) {
|
||||
}
|
||||
|
||||
chan.pushMessage(client, msg, !self);
|
||||
|
||||
// Do not send notifications for messages older than 15 minutes (znc buffer for example)
|
||||
if (highlight && (!data.time || data.time > Date.now() - 900000)) {
|
||||
let title = data.nick;
|
||||
|
||||
if (chan.type !== Chan.Type.QUERY) {
|
||||
title += ` (${chan.name}) mentioned you`;
|
||||
} else {
|
||||
title += " sent you a message";
|
||||
}
|
||||
|
||||
client.manager.webPush.push(client, {
|
||||
type: "notification",
|
||||
chanId: chan.id,
|
||||
timestamp: data.time || Date.now(),
|
||||
title: `The Lounge: ${title}`,
|
||||
body: Helper.cleanIrcMessage(data.message)
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
73
src/plugins/webpush.js
Normal file
73
src/plugins/webpush.js
Normal file
@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
const _ = require("lodash");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const WebPushAPI = require("web-push");
|
||||
const Helper = require("../helper");
|
||||
|
||||
class WebPush {
|
||||
constructor() {
|
||||
const vapidPath = path.join(Helper.HOME, "vapid.json");
|
||||
|
||||
if (fs.existsSync(vapidPath)) {
|
||||
const data = fs.readFileSync(vapidPath, "utf-8");
|
||||
const parsedData = JSON.parse(data);
|
||||
|
||||
if (typeof parsedData.publicKey === "string" && typeof parsedData.privateKey === "string") {
|
||||
this.vapidKeys = {
|
||||
publicKey: parsedData.publicKey,
|
||||
privateKey: parsedData.privateKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.vapidKeys) {
|
||||
this.vapidKeys = WebPushAPI.generateVAPIDKeys();
|
||||
|
||||
fs.writeFileSync(vapidPath, JSON.stringify(this.vapidKeys, null, "\t"));
|
||||
|
||||
log.info("New VAPID key pair has been generated for use with push subscription.");
|
||||
}
|
||||
|
||||
WebPushAPI.setVapidDetails(
|
||||
"https://github.com/thelounge/lounge",
|
||||
this.vapidKeys.publicKey,
|
||||
this.vapidKeys.privateKey
|
||||
);
|
||||
}
|
||||
|
||||
push(client, payload, onlyToOffline) {
|
||||
_.forOwn(client.config.sessions, (session, token) => {
|
||||
if (session.pushSubscription) {
|
||||
if (onlyToOffline && _.find(client.attachedClients, {token: token}) !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushSingle(client, session.pushSubscription, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pushSingle(client, subscription, payload) {
|
||||
WebPushAPI
|
||||
.sendNotification(subscription, JSON.stringify(payload))
|
||||
.catch((error) => {
|
||||
if (error.statusCode >= 400 && error.statusCode < 500) {
|
||||
log.warn(`WebPush subscription for ${client.name} returned an error (${error.statusCode}), removing subscription`);
|
||||
|
||||
_.forOwn(client.config.sessions, (session, token) => {
|
||||
if (session.pushSubscription && session.pushSubscription.endpoint === subscription.endpoint) {
|
||||
client.unregisterPushSubscription(token);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log.error("WebPush Error", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WebPush;
|
@ -180,7 +180,7 @@ function initializeClient(socket, client, generateToken, token) {
|
||||
socket.on("disconnect", function() {
|
||||
client.clientDetach(socket.id);
|
||||
});
|
||||
client.clientAttach(socket.id);
|
||||
client.clientAttach(socket.id, token);
|
||||
|
||||
socket.on(
|
||||
"input",
|
||||
@ -296,6 +296,31 @@ function initializeClient(socket, client, generateToken, token) {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("push:register", (subscription) => {
|
||||
if (!client.isRegistered() || !client.config.sessions[token]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = client.registerPushSubscription(client.config.sessions[token], subscription);
|
||||
|
||||
if (registration) {
|
||||
client.manager.webPush.pushSingle(client, registration, {
|
||||
type: "notification",
|
||||
timestamp: Date.now(),
|
||||
title: "The Lounge",
|
||||
body: "🚀 Push notifications have been enabled"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("push:unregister", () => {
|
||||
if (!client.isRegistered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.unregisterPushSubscription(token);
|
||||
});
|
||||
|
||||
socket.on("sign-out", () => {
|
||||
delete client.config.sessions[token];
|
||||
|
||||
@ -314,6 +339,8 @@ function initializeClient(socket, client, generateToken, token) {
|
||||
|
||||
const sendInitEvent = (tokenToSend) => {
|
||||
socket.emit("init", {
|
||||
applicationServerKey: manager.webPush.vapidKeys.publicKey,
|
||||
pushSubscription: client.config.sessions[token],
|
||||
active: client.lastActiveChannel,
|
||||
networks: client.networks,
|
||||
token: tokenToSend
|
||||
|
Loading…
Reference in New Issue
Block a user