diff --git a/commands.js b/commands.js index d881ec2..20db813 100644 --- a/commands.js +++ b/commands.js @@ -164,6 +164,13 @@ export default { app.disconnect(); }, }, + "clear-localstorage": { + description: "Clear application local storage", + execute: (app, args) => { + localStorage.clear(); + window.location.reload(); + }, + }, "help": { description: "Show help menu", execute: (app, args) => { diff --git a/components/app.js b/components/app.js index 4788788..dd5643e 100644 --- a/components/app.js +++ b/components/app.js @@ -28,6 +28,21 @@ const baseConfig = { server: {}, }; +function urlBase64ToUint8Array(base64String) { + var padding = '='.repeat((4 - base64String.length % 4) % 4); + var base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + var rawData = window.atob(base64); + var outputArray = new Uint8Array(rawData.length); + + for (var i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + const configPromise = fetch("./config.json") .then((resp) => { if (resp.ok) { @@ -227,6 +242,7 @@ export default class App extends Component { this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this); this.handleSettingsChange = this.handleSettingsChange.bind(this); this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this); + this.handleSettingsClearLocalStorage = this.handleSettingsClearLocalStorage.bind(this); this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this); this.handleWindowFocus = this.handleWindowFocus.bind(this); @@ -324,10 +340,6 @@ export default class App extends Component { this.debug = true; } - if (window.location.hash) { - autojoin = window.location.hash.split(","); - } - this.config = config; if (!connectParams.nick && connectParams.autoconnect) { @@ -367,8 +379,9 @@ export default class App extends Component { connectParams.saslOauthBearer = saslOauthBearer; - if (saslOauthBearer.username && !connectParams.nick) { + if (saslOauthBearer.username) { connectParams.nick = saslOauthBearer.username; + connectParams.username = saslOauthBearer.username; } } @@ -386,6 +399,9 @@ export default class App extends Component { if (connectParams.autoconnect) { this.setState({ connectForm: false }); + setTimeout(function() { + store.autoconnect.put(connectParams); + }, 500); this.connect(connectParams); } } @@ -790,6 +806,40 @@ export default class App extends Component { if (errorID) { this.dismissError(errorID); } + + function handlePush(data) { + const bouncerNetwork = data.message.tags.bouncerNetwork + const serverID = this.serverFromBouncerNetwork(bouncerNetwork); + + let bufferName = data.message.params[0]; + if (bufferName == client.nick) { + bufferName = data.message.prefix.name; + } + + this.switchBuffer({ server: serverID, name: bufferName }); + } + handlePush = handlePush.bind(this); + + window.addEventListener("pushMessageReceived", (event) => { + if (event.detail.message.tags.bouncerNetwork !== client.params.bouncerNetwork) { + return; + } + + handlePush(event.detail); + }); + + if (window.notificationData) { + if (window.notificationData.message.tags.bouncerNetwork !== client.params.bouncerNetwork) { + break; + } + + setTimeout(() => { + if (window.notificationData) { + handlePush(window.notificationData); + window.notificationData = null; + } + }, 500); + } break; } }); @@ -981,9 +1031,50 @@ export default class App extends Component { affectedBuffers.push(prefix.name); } return affectedBuffers; + case irc.RPL_ISUPPORT: { + const params = msg.params; + for (const p of params) { + if (p.indexOf("VAPID") === 0) { + const value = p.replace("VAPID=", ""); + const vapidPubKey = urlBase64ToUint8Array(value); + + navigator.serviceWorker.ready.then((registration) => { + return registration.pushManager.getSubscription() + .then((subscription) => { + if (subscription) { + return subscription; + } + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPubKey, + }); + }).then((subscription) => { + var filterCommands = [ + "PRIVMSG", + ]; + + if (client.params.bouncerNetwork == null) { + filterCommands.push("NOTE"); + } + + var data = subscription.toJSON(); + data.keys.filterCommands =filterCommands.join(","); + var keysStr = irc.formatTags(data.keys); + + client.send({ + command: "WEBPUSH", + params: ["REGISTER", data.endpoint, keysStr], + }); + + return [SERVER_BUFFER]; + }); + }); + } + } + return []; + } case irc.RPL_YOURHOST: case irc.RPL_MYINFO: - case irc.RPL_ISUPPORT: case irc.RPL_ENDOFMOTD: case irc.ERR_NOMOTD: case irc.RPL_AWAY: @@ -1913,6 +2004,11 @@ export default class App extends Component { this.disconnectAll(); } + handleSettingsClearLocalStorage() { + localStorage.clear(); + window.location.reload(); + } + handleSwitchSubmit(buf) { this.dismissDialog(); if (buf) { @@ -2112,6 +2208,7 @@ export default class App extends Component { showProtocolHandler=${dialogData.showProtocolHandler} onChange=${this.handleSettingsChange} onDisconnect=${this.handleSettingsDisconnect} + onClearLocalStorage=${this.handleSettingsClearLocalStorage} onClose=${this.dismissDialog} /> diff --git a/components/connect-form.js b/components/connect-form.js index 6eab2df..3358ad2 100644 --- a/components/connect-form.js +++ b/components/connect-form.js @@ -7,7 +7,7 @@ export default class ConnectForm extends Component { pass: "", nick: "", password: "", - rememberMe: false, + rememberMe: true, username: "", realname: "", autojoin: true, diff --git a/components/settings-form.js b/components/settings-form.js index 31e045e..a09a084 100644 --- a/components/settings-form.js +++ b/components/settings-form.js @@ -103,6 +103,9 @@ export default class SettingsForm extends Component { + diff --git a/lib/client.js b/lib/client.js index a1a969a..90e736e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -25,6 +25,7 @@ const permanentCaps = [ "draft/read-marker", "soju.im/bouncer-networks", + "soju.im/webpush", ]; const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s diff --git a/lib/index.js b/lib/index.js index 1b480b3..e214ff5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,3 +6,6 @@ export const html = htm.bind(h); import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js"; export { linkifyjs }; + +import * as idbkv from "../node_modules/idb-keyval/dist/index.js"; +export { idbkv }; diff --git a/main.js b/main.js index 2b73e7d..9f1f905 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,54 @@ -import { html, render } from "./lib/index.js"; +import { html, render, idbkv } from "./lib/index.js"; import App from "./components/app.js"; +(function () { + let updated = false; + let activated = false; + + if (!'serviceWorker' in navigator) { + return; + } + + navigator.serviceWorker.addEventListener('controllerchange', () => { + updated = true; + checkUpdate(); + }); + + navigator.serviceWorker.addEventListener("message", (event) => { + const data = event.data; + + window.dispatchEvent(new CustomEvent("pushMessageReceived", { + detail: data, + })); + }); + + idbkv.get("notification").then((data) => { + if (data) { + window.notificationData = data; + } + return idbkv.del("notification"); + }); + + navigator.serviceWorker.register( + new URL('service-worker.js', import.meta.url), + {type: 'module'} + ).then((registration) => { + registration.addEventListener("updatefound", () => { + const worker = registration.installing; + worker.addEventListener('statechange', () => { + if (worker.state === "activated") { + activated = true; + checkUpdate(); + } + }); + }); + }).catch(console.error); + + function checkUpdate() { + if (activated && updated) { + window.location.reload(); + } + } +})(); + render(html`<${App}/>`, document.body); diff --git a/package-lock.json b/package-lock.json index 940446d..2179582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "gamja", "dependencies": { "htm": "^3.0.4", + "idb-keyval": "^6.2.1", "linkifyjs": "^3.0.2", "preact": "^10.5.9" }, @@ -2663,6 +2664,11 @@ "entities": "^3.0.1" } }, + "node_modules/idb-keyval": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", diff --git a/package.json b/package.json index 3628469..fe89f53 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "type": "module", "dependencies": { "htm": "^3.0.4", + "idb-keyval": "^6.2.1", "linkifyjs": "^3.0.2", "preact": "^10.5.9" }, diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..7cdeb56 --- /dev/null +++ b/service-worker.js @@ -0,0 +1,70 @@ +import * as irc from "./lib/irc.js"; +import { idbkv } from "./lib/index.js"; + +self.addEventListener("push", async (event) => { + let title, body, parsedMessage; + + try { + parsedMessage = irc.parseMessage(event.data.text()); + + switch(parsedMessage.command) { + case "PRIVMSG": + title = `${parsedMessage.prefix.name} (${parsedMessage.params[0]}) says:`; + body = parsedMessage.params[1]; + break; + case "NOTE": + if (parsedMessage.params.length > 1 && parsedMessage.params[0] == "WEBPUSH" && parsedMessage.params[1] == "REGISTERED") { + title = "Push notifications enabled"; + body = "Successfully registered webpush token" + break; + } + title = parsedMessage.command; + body = parsedMessage.params.join(" "); + break; + default: + title = parsedMessage.command; + body = JSON.stringify(parsedMessage); + break; + } + } catch (err) { + title = event.data.text() + body = event.data.text() + console.error("Error parsing irc message in service worker:", err); + } + + const data = { + message: parsedMessage + }; + + event.waitUntil(idbkv.set("notification", data).then(() => { + self.registration.showNotification(title, { + body, + data, + }) + })); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil(clients.matchAll({ + includeUncontrolled: true, + }).then(async (clientList) => { + for (const client of clientList) { + if ("focus" in client) { + return client.focus(); + } + return client; + } + if ("openWindow" in clients) { + return clients.openWindow("/"); + } + }).then((client) => { + if (client) { + client.postMessage(event.notification.data); + } + })); +}); +self.addEventListener('install', () => { }); +self.addEventListener('activate', () => { }); +self.addEventListener('fetch', () => { });