diff --git a/README.md b/README.md
index dc60ec01..27b4cf49 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/client/css/style.css b/client/css/style.css
index f1a87cd1..6844fc9e 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -688,7 +688,8 @@ kbd {
width: 100%;
}
-#windows label {
+#windows label,
+#settings .error {
font-size: 14px;
}
diff --git a/client/index.html b/client/index.html
index 09baadb5..d3dac9d5 100644
--- a/client/index.html
+++ b/client/index.html
@@ -293,20 +293,36 @@
{{/if}}
+ {{#unless public}}
-
Notifications
+ Push Notifications
+
+
+
Subscribe to push notifications
+
+ Warning :
+ Push notifications are only supported over HTTPS connections.
+
+
+ Warning :
+ Push notifications are not supported by your browser.
+
+
+ {{/unless}}
+
+
Browser Notifications
diff --git a/client/js/lounge.js b/client/js/lounge.js
index ec937737..25e67f4b 100644
--- a/client/js/lounge.js
+++ b/client/js/lounge.js
@@ -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");
diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js
index cf8b464b..a78c07fa 100644
--- a/client/js/socket-events/init.js
+++ b/client/js/socket-events/init.js
@@ -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();
diff --git a/client/js/webpush.js b/client/js/webpush.js
new file mode 100644
index 00000000..87306f16
--- /dev/null
+++ b/client/js/webpush.js
@@ -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";
+}
diff --git a/client/service-worker.js b/client/service-worker.js
new file mode 100644
index 00000000..fcb63300
--- /dev/null
+++ b/client/service-worker.js
@@ -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(".");
+ }
+ }));
+});
diff --git a/package.json b/package.json
index 89bb2e82..e7fcba68 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/client.js b/src/client.js
index a7bb59cf..f259030f 100644
--- a/src/client.js
+++ b/src/client.js
@@ -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;
diff --git a/src/clientManager.js b/src/clientManager.js
index c50ccf92..bec7c314 100644
--- a/src/clientManager.js
+++ b/src/clientManager.js
@@ -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) {
diff --git a/src/helper.js b/src/helper.js
index 93b968b6..288dcfec 100644
--- a/src/helper.js
+++ b/src/helper.js
@@ -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;
}
diff --git a/src/models/chan.js b/src/models/chan.js
index 2fa194a2..e06e264f 100644
--- a/src/models/chan.js
+++ b/src/models/chan.js
@@ -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;
diff --git a/src/plugins/irc-events/link.js b/src/plugins/irc-events/link.js
index 49e9f14e..06049b15 100644
--- a/src/plugins/irc-events/link.js
+++ b/src/plugins/irc-events/link.js
@@ -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));
diff --git a/src/plugins/irc-events/message.js b/src/plugins/irc-events/message.js
index a358a74a..fbfc7102 100644
--- a/src/plugins/irc-events/message.js
+++ b/src/plugins/irc-events/message.js
@@ -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);
+ }
}
};
diff --git a/src/plugins/webpush.js b/src/plugins/webpush.js
new file mode 100644
index 00000000..5a5e4341
--- /dev/null
+++ b/src/plugins/webpush.js
@@ -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;
diff --git a/src/server.js b/src/server.js
index 8dc20c52..d8c4626b 100644
--- a/src/server.js
+++ b/src/server.js
@@ -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
diff --git a/test/tests/cleanircmessages.js b/test/tests/cleanircmessages.js
new file mode 100644
index 00000000..6a3877c6
--- /dev/null
+++ b/test/tests/cleanircmessages.js
@@ -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);
+ });
+});