1diff --git a/commands.js b/commands.js
2index d881ec2..20db813 100644
3--- a/commands.js
4+++ b/commands.js
5@@ -164,6 +164,13 @@ export default {
6 app.disconnect();
7 },
8 },
9+ "clear-localstorage": {
10+ description: "Clear application local storage",
11+ execute: (app, args) => {
12+ localStorage.clear();
13+ window.location.reload();
14+ },
15+ },
16 "help": {
17 description: "Show help menu",
18 execute: (app, args) => {
19diff --git a/components/app.js b/components/app.js
20index 4788788..dd5643e 100644
21--- a/components/app.js
22+++ b/components/app.js
23@@ -28,6 +28,21 @@ const baseConfig = {
24 server: {},
25 };
26
27+function urlBase64ToUint8Array(base64String) {
28+ var padding = '='.repeat((4 - base64String.length % 4) % 4);
29+ var base64 = (base64String + padding)
30+ .replace(/\-/g, '+')
31+ .replace(/_/g, '/');
32+
33+ var rawData = window.atob(base64);
34+ var outputArray = new Uint8Array(rawData.length);
35+
36+ for (var i = 0; i < rawData.length; ++i) {
37+ outputArray[i] = rawData.charCodeAt(i);
38+ }
39+ return outputArray;
40+}
41+
42 const configPromise = fetch("./config.json")
43 .then((resp) => {
44 if (resp.ok) {
45@@ -227,6 +242,7 @@ export default class App extends Component {
46 this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
47 this.handleSettingsChange = this.handleSettingsChange.bind(this);
48 this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
49+ this.handleSettingsClearLocalStorage = this.handleSettingsClearLocalStorage.bind(this);
50 this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
51 this.handleWindowFocus = this.handleWindowFocus.bind(this);
52
53@@ -324,10 +340,6 @@ export default class App extends Component {
54 this.debug = true;
55 }
56
57- if (window.location.hash) {
58- autojoin = window.location.hash.split(",");
59- }
60-
61 this.config = config;
62
63 if (!connectParams.nick && connectParams.autoconnect) {
64@@ -367,8 +379,9 @@ export default class App extends Component {
65
66 connectParams.saslOauthBearer = saslOauthBearer;
67
68- if (saslOauthBearer.username && !connectParams.nick) {
69+ if (saslOauthBearer.username) {
70 connectParams.nick = saslOauthBearer.username;
71+ connectParams.username = saslOauthBearer.username;
72 }
73 }
74
75@@ -386,6 +399,9 @@ export default class App extends Component {
76
77 if (connectParams.autoconnect) {
78 this.setState({ connectForm: false });
79+ setTimeout(function() {
80+ store.autoconnect.put(connectParams);
81+ }, 500);
82 this.connect(connectParams);
83 }
84 }
85@@ -790,6 +806,40 @@ export default class App extends Component {
86 if (errorID) {
87 this.dismissError(errorID);
88 }
89+
90+ function handlePush(data) {
91+ const bouncerNetwork = data.message.tags.bouncerNetwork
92+ const serverID = this.serverFromBouncerNetwork(bouncerNetwork);
93+
94+ let bufferName = data.message.params[0];
95+ if (bufferName == client.nick) {
96+ bufferName = data.message.prefix.name;
97+ }
98+
99+ this.switchBuffer({ server: serverID, name: bufferName });
100+ }
101+ handlePush = handlePush.bind(this);
102+
103+ window.addEventListener("pushMessageReceived", (event) => {
104+ if (event.detail.message.tags.bouncerNetwork !== client.params.bouncerNetwork) {
105+ return;
106+ }
107+
108+ handlePush(event.detail);
109+ });
110+
111+ if (window.notificationData) {
112+ if (window.notificationData.message.tags.bouncerNetwork !== client.params.bouncerNetwork) {
113+ break;
114+ }
115+
116+ setTimeout(() => {
117+ if (window.notificationData) {
118+ handlePush(window.notificationData);
119+ window.notificationData = null;
120+ }
121+ }, 500);
122+ }
123 break;
124 }
125 });
126@@ -981,9 +1031,50 @@ export default class App extends Component {
127 affectedBuffers.push(prefix.name);
128 }
129 return affectedBuffers;
130+ case irc.RPL_ISUPPORT: {
131+ const params = msg.params;
132+ for (const p of params) {
133+ if (p.indexOf("VAPID") === 0) {
134+ const value = p.replace("VAPID=", "");
135+ const vapidPubKey = urlBase64ToUint8Array(value);
136+
137+ navigator.serviceWorker.ready.then((registration) => {
138+ return registration.pushManager.getSubscription()
139+ .then((subscription) => {
140+ if (subscription) {
141+ return subscription;
142+ }
143+ return registration.pushManager.subscribe({
144+ userVisibleOnly: true,
145+ applicationServerKey: vapidPubKey,
146+ });
147+ }).then((subscription) => {
148+ var filterCommands = [
149+ "PRIVMSG",
150+ ];
151+
152+ if (client.params.bouncerNetwork == null) {
153+ filterCommands.push("NOTE");
154+ }
155+
156+ var data = subscription.toJSON();
157+ data.keys.filterCommands =filterCommands.join(",");
158+ var keysStr = irc.formatTags(data.keys);
159+
160+ client.send({
161+ command: "WEBPUSH",
162+ params: ["REGISTER", data.endpoint, keysStr],
163+ });
164+
165+ return [SERVER_BUFFER];
166+ });
167+ });
168+ }
169+ }
170+ return [];
171+ }
172 case irc.RPL_YOURHOST:
173 case irc.RPL_MYINFO:
174- case irc.RPL_ISUPPORT:
175 case irc.RPL_ENDOFMOTD:
176 case irc.ERR_NOMOTD:
177 case irc.RPL_AWAY:
178@@ -1913,6 +2004,11 @@ export default class App extends Component {
179 this.disconnectAll();
180 }
181
182+ handleSettingsClearLocalStorage() {
183+ localStorage.clear();
184+ window.location.reload();
185+ }
186+
187 handleSwitchSubmit(buf) {
188 this.dismissDialog();
189 if (buf) {
190@@ -2112,6 +2208,7 @@ export default class App extends Component {
191 showProtocolHandler=${dialogData.showProtocolHandler}
192 onChange=${this.handleSettingsChange}
193 onDisconnect=${this.handleSettingsDisconnect}
194+ onClearLocalStorage=${this.handleSettingsClearLocalStorage}
195 onClose=${this.dismissDialog}
196 />
197 </>
198diff --git a/components/connect-form.js b/components/connect-form.js
199index 6eab2df..3358ad2 100644
200--- a/components/connect-form.js
201+++ b/components/connect-form.js
202@@ -7,7 +7,7 @@ export default class ConnectForm extends Component {
203 pass: "",
204 nick: "",
205 password: "",
206- rememberMe: false,
207+ rememberMe: true,
208 username: "",
209 realname: "",
210 autojoin: true,
211diff --git a/components/settings-form.js b/components/settings-form.js
212index 31e045e..a09a084 100644
213--- a/components/settings-form.js
214+++ b/components/settings-form.js
215@@ -103,6 +103,9 @@ export default class SettingsForm extends Component {
216 <button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
217 Disconnect
218 </button>
219+ <button type="button" class="danger" onClick=${() => this.props.onClearLocalStorage()}>
220+ Clear LocalStorage
221+ </button>
222 <button>
223 Close
224 </button>
225diff --git a/lib/client.js b/lib/client.js
226index a1a969a..90e736e 100644
227--- a/lib/client.js
228+++ b/lib/client.js
229@@ -25,6 +25,7 @@ const permanentCaps = [
230 "draft/read-marker",
231
232 "soju.im/bouncer-networks",
233+ "soju.im/webpush",
234 ];
235
236 const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
237diff --git a/lib/index.js b/lib/index.js
238index 1b480b3..e214ff5 100644
239--- a/lib/index.js
240+++ b/lib/index.js
241@@ -6,3 +6,6 @@ export const html = htm.bind(h);
242
243 import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
244 export { linkifyjs };
245+
246+import * as idbkv from "../node_modules/idb-keyval/dist/index.js";
247+export { idbkv };
248diff --git a/main.js b/main.js
249index 2b73e7d..9f1f905 100644
250--- a/main.js
251+++ b/main.js
252@@ -1,4 +1,54 @@
253-import { html, render } from "./lib/index.js";
254+import { html, render, idbkv } from "./lib/index.js";
255 import App from "./components/app.js";
256
257+(function () {
258+ let updated = false;
259+ let activated = false;
260+
261+ if (!'serviceWorker' in navigator) {
262+ return;
263+ }
264+
265+ navigator.serviceWorker.addEventListener('controllerchange', () => {
266+ updated = true;
267+ checkUpdate();
268+ });
269+
270+ navigator.serviceWorker.addEventListener("message", (event) => {
271+ const data = event.data;
272+
273+ window.dispatchEvent(new CustomEvent("pushMessageReceived", {
274+ detail: data,
275+ }));
276+ });
277+
278+ idbkv.get("notification").then((data) => {
279+ if (data) {
280+ window.notificationData = data;
281+ }
282+ return idbkv.del("notification");
283+ });
284+
285+ navigator.serviceWorker.register(
286+ new URL('service-worker.js', import.meta.url),
287+ {type: 'module'}
288+ ).then((registration) => {
289+ registration.addEventListener("updatefound", () => {
290+ const worker = registration.installing;
291+ worker.addEventListener('statechange', () => {
292+ if (worker.state === "activated") {
293+ activated = true;
294+ checkUpdate();
295+ }
296+ });
297+ });
298+ }).catch(console.error);
299+
300+ function checkUpdate() {
301+ if (activated && updated) {
302+ window.location.reload();
303+ }
304+ }
305+})();
306+
307 render(html`<${App}/>`, document.body);
308diff --git a/package-lock.json b/package-lock.json
309index 940446d..2179582 100644
310--- a/package-lock.json
311+++ b/package-lock.json
312@@ -7,6 +7,7 @@
313 "name": "gamja",
314 "dependencies": {
315 "htm": "^3.0.4",
316+ "idb-keyval": "^6.2.1",
317 "linkifyjs": "^3.0.2",
318 "preact": "^10.5.9"
319 },
320@@ -2663,6 +2664,11 @@
321 "entities": "^3.0.1"
322 }
323 },
324+ "node_modules/idb-keyval": {
325+ "version": "6.2.1",
326+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
327+ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
328+ },
329 "node_modules/import-fresh": {
330 "version": "3.3.0",
331 "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
332diff --git a/package.json b/package.json
333index 3628469..fe89f53 100644
334--- a/package.json
335+++ b/package.json
336@@ -3,6 +3,7 @@
337 "type": "module",
338 "dependencies": {
339 "htm": "^3.0.4",
340+ "idb-keyval": "^6.2.1",
341 "linkifyjs": "^3.0.2",
342 "preact": "^10.5.9"
343 },
344diff --git a/service-worker.js b/service-worker.js
345new file mode 100644
346index 0000000..7cdeb56
347--- /dev/null
348+++ b/service-worker.js
349@@ -0,0 +1,70 @@
350+import * as irc from "./lib/irc.js";
351+import { idbkv } from "./lib/index.js";
352+
353+self.addEventListener("push", async (event) => {
354+ let title, body, parsedMessage;
355+
356+ try {
357+ parsedMessage = irc.parseMessage(event.data.text());
358+
359+ switch(parsedMessage.command) {
360+ case "PRIVMSG":
361+ title = `${parsedMessage.prefix.name} (${parsedMessage.params[0]}) says:`;
362+ body = parsedMessage.params[1];
363+ break;
364+ case "NOTE":
365+ if (parsedMessage.params.length > 1 && parsedMessage.params[0] == "WEBPUSH" && parsedMessage.params[1] == "REGISTERED") {
366+ title = "Push notifications enabled";
367+ body = "Successfully registered webpush token"
368+ break;
369+ }
370+ title = parsedMessage.command;
371+ body = parsedMessage.params.join(" ");
372+ break;
373+ default:
374+ title = parsedMessage.command;
375+ body = JSON.stringify(parsedMessage);
376+ break;
377+ }
378+ } catch (err) {
379+ title = event.data.text()
380+ body = event.data.text()
381+ console.error("Error parsing irc message in service worker:", err);
382+ }
383+
384+ const data = {
385+ message: parsedMessage
386+ };
387+
388+ event.waitUntil(idbkv.set("notification", data).then(() => {
389+ self.registration.showNotification(title, {
390+ body,
391+ data,
392+ })
393+ }));
394+});
395+
396+self.addEventListener('notificationclick', (event) => {
397+ event.notification.close();
398+
399+ event.waitUntil(clients.matchAll({
400+ includeUncontrolled: true,
401+ }).then(async (clientList) => {
402+ for (const client of clientList) {
403+ if ("focus" in client) {
404+ return client.focus();
405+ }
406+ return client;
407+ }
408+ if ("openWindow" in clients) {
409+ return clients.openWindow("/");
410+ }
411+ }).then((client) => {
412+ if (client) {
413+ client.postMessage(event.notification.data);
414+ }
415+ }));
416+});
417+self.addEventListener('install', () => { });
418+self.addEventListener('activate', () => { });
419+self.addEventListener('fetch', () => { });