Merge pull request #1124 from thelounge/xpaw/push-notifications

Implement push notifications
This commit is contained in:
Pavel Djundik 2017-08-22 23:16:41 +03:00 committed by GitHub
commit fcd9782eb7
17 changed files with 429 additions and 16 deletions

View File

@ -10,7 +10,7 @@ Modern web IRC client designed for self-hosting.
## Overview ## 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. * **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. * **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. * **Responsive interface.** The client works smoothly on every desktop, smartphone and tablet.

View File

@ -688,7 +688,8 @@ kbd {
width: 100%; width: 100%;
} }
#windows label { #windows label,
#settings .error {
font-size: 14px; font-size: 14px;
} }

View File

@ -293,20 +293,36 @@
</label> </label>
</div> </div>
{{/if}} {{/if}}
{{#unless public}}
<div class="col-sm-12"> <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>
<div class="col-sm-12"> <div class="col-sm-12">
<label class="opt"> <label class="opt">
<input id="desktopNotifications" type="checkbox" name="desktopNotifications"> <input id="desktopNotifications" type="checkbox" name="desktopNotifications">
Enable desktop notifications<br> Enable browser notifications<br>
<div class="error" id="warnUnsupportedDesktopNotifications"> <div class="error" id="warnUnsupportedDesktopNotifications">
<strong>Warning</strong>: <strong>Warning</strong>:
Desktop notifications are not supported by your browser. Notifications are not supported by your browser.
</div> </div>
<div class="error" id="warnBlockedDesktopNotifications"> <div class="error" id="warnBlockedDesktopNotifications">
<strong>Warning</strong>: <strong>Warning</strong>:
Desktop notifications are blocked by your browser. Notifications are blocked by your browser.
</div> </div>
</label> </label>
</div> </div>

View File

@ -22,6 +22,7 @@ require("./socket-events");
const constants = require("./constants"); const constants = require("./constants");
const storage = require("./localStorage"); const storage = require("./localStorage");
const utils = require("./utils"); const utils = require("./utils");
require("./webpush");
$(function() { $(function() {
var sidebar = $("#sidebar, #footer"); var sidebar = $("#sidebar, #footer");

View File

@ -3,6 +3,7 @@
const $ = require("jquery"); const $ = require("jquery");
const socket = require("../socket"); const socket = require("../socket");
const render = require("../render"); const render = require("../render");
const webpush = require("../webpush");
const sidebar = $("#sidebar"); const sidebar = $("#sidebar");
const storage = require("../localStorage"); const storage = require("../localStorage");
@ -21,6 +22,8 @@ socket.on("init", function(data) {
storage.set("token", data.token); storage.set("token", data.token);
} }
webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
$("body").removeClass("signed-out"); $("body").removeClass("signed-out");
$("#loading").remove(); $("#loading").remove();
$("#sign-in").remove(); $("#sign-in").remove();

127
client/js/webpush.js Normal file
View 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
View 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(".");
}
}));
});

View File

@ -58,7 +58,8 @@
"socket.io": "1.7.4", "socket.io": "1.7.4",
"spdy": "3.4.7", "spdy": "3.4.7",
"ua-parser-js": "0.7.14", "ua-parser-js": "0.7.14",
"urijs": "1.18.12" "urijs": "1.18.12",
"web-push": "3.2.2"
}, },
"devDependencies": { "devDependencies": {
"babel-core": "6.26.0", "babel-core": "6.26.0",

View File

@ -94,11 +94,21 @@ function Client(manager, name, config) {
client.config.sessions = {}; client.config.sessions = {};
} }
_.forOwn(client.config.sessions, (session) => {
if (session.pushSubscription) {
this.registerPushSubscription(session, session.pushSubscription, true);
}
});
if (client.name) { if (client.name) {
log.info(`User ${colors.bold(client.name)} loaded`); log.info(`User ${colors.bold(client.name)} loaded`);
} }
} }
Client.prototype.isRegistered = function() {
return this.name.length > 0;
};
Client.prototype.emit = function(event, data) { Client.prototype.emit = function(event, data) {
if (this.sockets !== null) { if (this.sockets !== null) {
this.sockets.in(this.id).emit(event, data); 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}`; friendlyAgent += ` on ${agent.os.name} ${agent.os.version}`;
} }
client.config.sessions[token] = { client.config.sessions[token] = _.assign({
lastUse: Date.now(), lastUse: Date.now(),
ip: ip, ip: ip,
agent: friendlyAgent, agent: friendlyAgent,
}; }, client.config.sessions[token]);
}; };
Client.prototype.setPassword = function(hash, callback) { Client.prototype.setPassword = function(hash, callback) {
@ -410,7 +420,7 @@ Client.prototype.more = function(data) {
Client.prototype.open = function(socketId, target) { Client.prototype.open = function(socketId, target) {
// Opening a window like settings // Opening a window like settings
if (target === null) { if (target === null) {
this.attachedClients[socketId] = -1; this.attachedClients[socketId].openChannel = -1;
return; return;
} }
@ -423,7 +433,7 @@ Client.prototype.open = function(socketId, target) {
target.chan.unread = 0; target.chan.unread = 0;
target.chan.highlight = false; target.chan.highlight = false;
this.attachedClients[socketId] = target.chan.id; this.attachedClients[socketId].openChannel = target.chan.id;
this.lastActiveChannel = target.chan.id; this.lastActiveChannel = target.chan.id;
this.emit("open", 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 client = this;
var save = false; 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 // Update old networks to store ip and hostmask
client.networks.forEach((network) => { 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() { Client.prototype.save = _.debounce(function SaveClient() {
if (Helper.config.public) { if (Helper.config.public) {
return; return;

View File

@ -5,6 +5,7 @@ var colors = require("colors/safe");
var fs = require("fs"); var fs = require("fs");
var Client = require("./client"); var Client = require("./client");
var Helper = require("./helper"); var Helper = require("./helper");
const WebPush = require("./plugins/webpush");
module.exports = ClientManager; module.exports = ClientManager;
@ -15,6 +16,7 @@ function ClientManager() {
ClientManager.prototype.init = function(identHandler, sockets) { ClientManager.prototype.init = function(identHandler, sockets) {
this.sockets = sockets; this.sockets = sockets;
this.identHandler = identHandler; this.identHandler = identHandler;
this.webPush = new WebPush();
if (!Helper.config.public) { if (!Helper.config.public) {
if ("autoload" in Helper.config) { if ("autoload" in Helper.config) {

View File

@ -19,6 +19,7 @@ var Helper = {
getVersion: getVersion, getVersion: getVersion,
getGitCommit: getGitCommit, getGitCommit: getGitCommit,
ip2hex: ip2hex, ip2hex: ip2hex,
cleanIrcMessage: cleanIrcMessage,
password: { password: {
hash: passwordHash, hash: passwordHash,
@ -121,6 +122,11 @@ function expandHome(shortenedPath) {
return path.resolve(shortenedPath.replace(/^~($|\/|\\)/, home + "$1")); 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) { function passwordRequiresUpdate(password) {
return bcrypt.getRounds(password) !== 11; return bcrypt.getRounds(password) !== 11;
} }

View File

@ -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 // 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) { if ((increasesUnread || msg.highlight) && !isOpen) {
obj.unread = ++this.unread; obj.unread = ++this.unread;

View File

@ -16,7 +16,7 @@ module.exports = function(client, chan, msg) {
} }
// Remove all IRC formatting characters before searching for links // 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 // We will only try to prefetch http(s) links
const links = findLinks(cleanText).filter((w) => /^https?:\/\//.test(w.link)); const links = findLinks(cleanText).filter((w) => /^https?:\/\//.test(w.link));

View File

@ -3,6 +3,7 @@
const Chan = require("../../models/chan"); const Chan = require("../../models/chan");
const Msg = require("../../models/msg"); const Msg = require("../../models/msg");
const LinkPrefetch = require("./link"); const LinkPrefetch = require("./link");
const Helper = require("../../helper");
module.exports = function(irc, network) { module.exports = function(irc, network) {
var client = this; var client = this;
@ -102,5 +103,24 @@ module.exports = function(irc, network) {
} }
chan.pushMessage(client, msg, !self); 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
View 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;

View File

@ -180,7 +180,7 @@ function initializeClient(socket, client, generateToken, token) {
socket.on("disconnect", function() { socket.on("disconnect", function() {
client.clientDetach(socket.id); client.clientDetach(socket.id);
}); });
client.clientAttach(socket.id); client.clientAttach(socket.id, token);
socket.on( socket.on(
"input", "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", () => { socket.on("sign-out", () => {
delete client.config.sessions[token]; delete client.config.sessions[token];
@ -314,6 +339,8 @@ function initializeClient(socket, client, generateToken, token) {
const sendInitEvent = (tokenToSend) => { const sendInitEvent = (tokenToSend) => {
socket.emit("init", { socket.emit("init", {
applicationServerKey: manager.webPush.vapidKeys.publicKey,
pushSubscription: client.config.sessions[token],
active: client.lastActiveChannel, active: client.lastActiveChannel,
networks: client.networks, networks: client.networks,
token: tokenToSend token: tokenToSend

View File

@ -0,0 +1,48 @@
"use strict";
const expect = require("chai").expect;
const Helper = require("../../src/helper");
describe("Clean IRC messages", function() {
it("should remove all formatting", function() {
const testCases = [{
input: "\x0303",
expected: ""
}, {
input: "\x02bold",
expected: "bold"
}, {
input: "\x038yellowText",
expected: "yellowText"
}, {
input: "\x030,0white,white",
expected: "white,white"
}, {
input: "\x034,8yellowBGredText",
expected: "yellowBGredText"
}, {
input: "\x1ditalic",
expected: "italic"
}, {
input: "\x1funderline",
expected: "underline"
}, {
input: "\x02bold\x038yellow\x02nonBold\x03default",
expected: "boldyellownonBolddefault"
}, {
input: "\x02bold\x02 \x02bold\x02",
expected: "bold bold"
}, {
input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge",
expected: "irc://freenode.net/thelounge"
}, {
input: "\x02#\x038,9thelounge",
expected: "#thelounge"
}];
const actual = testCases.map((testCase) => Helper.cleanIrcMessage(testCase.input));
const expected = testCases.map((testCase) => testCase.expected);
expect(actual).to.deep.equal(expected);
});
});