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', () => { });