aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2020-06-13 21:39:22 -0700
committerAdam Soltys <[email protected]>2020-06-13 21:39:22 -0700
commit74b7d2a0d614f170ba5aea3a1a172a7a68356d80 (patch)
treebe2a73a9917616c0b49c24dc99642378e5a47549 /lib
parentdcb21adb40a44c8108f1d652cc6ddd59ae7c4024 (diff)
downloadcoinos-server-74b7d2a0d614f170ba5aea3a1a172a7a68356d80.tar.gz
coinos-server-74b7d2a0d614f170ba5aea3a1a172a7a68356d80.zip
push notifications
Diffstat (limited to 'lib')
-rw-r--r--lib/notifications.js23
-rw-r--r--lib/sockets.js93
2 files changed, 79 insertions, 37 deletions
diff --git a/lib/notifications.js b/lib/notifications.js
new file mode 100644
index 00000000..710a5bfe
--- /dev/null
+++ b/lib/notifications.js
@@ -0,0 +1,23 @@
+const webpush = require("web-push");
+
+if (config.vapid) {
+ webpush.setVapidDetails(
+ config.vapid.url,
+ config.vapid.publicKey,
+ config.vapid.privateKey
+ );
+}
+
+notify = (user, payload) => {
+ let { subscriptions } = user;
+
+ if (subscriptions) {
+ subscriptions.map(async subscription => {
+ try {
+ await webpush.sendNotification(subscription, payload);
+ } catch (e) {
+ l.error("problem sending notification", e);
+ }
+ });
+ }
+};
diff --git a/lib/sockets.js b/lib/sockets.js
index 89beffb3..07af88d8 100644
--- a/lib/sockets.js
+++ b/lib/sockets.js
@@ -37,47 +37,66 @@ const names = {};
wss.on("connection", function(ws, req) {
const id = uuidv4();
- setTimeout(() => ws.ok || ws.close(), 2000);
+ setTimeout(() => ws.user || ws.close(), 2000);
- ws.on("message", async token => {
- if (!token || token === "null") return;
+ ws.on("message", async (message) => {
+ let type, data;
try {
- const username = jwt.decode(token).username;
- if (!sockets[username]) sockets[username] = {};
- sockets[username][id] = ws;
- names[id] = username;
+ ({ type, data } = JSON.parse(message));
+ } catch (e) {
+ l.error("coudn't parse socket message");
+ }
- let user = await getUser(username);
- let payments = await user.getPayments({
- where: {
- account_id: user.account_id,
- },
- order: [["id", "DESC"]],
- limit: 12,
- include: {
- model: db.Account,
- as: "account"
- }
- });
- let accounts = await user.getAccounts();
- user = user.get({ plain: true });
- user.accounts = accounts;
- user.payments = payments;
+ let token;
+ switch (type) {
+ case "login":
+ token = data;
+ if (!token || token === "null") return;
+ try {
+ const username = jwt.decode(token).username;
+ if (!sockets[username]) sockets[username] = {};
+ sockets[username][id] = ws;
+ names[id] = username;
- ws.ok = true;
- ws.send(JSON.stringify({ type: "nodes", data: networks }));
- ws.send(JSON.stringify({ type: "rates", data: app.get("rates") }));
- ws.send(JSON.stringify({ type: "login", data: user }));
- l.info("socket open", username, id);
- } catch (e) {
- ws.close();
- l.error(
- "problem opening socket",
- token,
- e.message,
- id,
- req.connection.remoteAddress
- );
+ let user = await getUser(username);
+ let payments = await user.getPayments({
+ where: {
+ account_id: user.account_id
+ },
+ order: [["id", "DESC"]],
+ limit: 12,
+ include: {
+ model: db.Account,
+ as: "account"
+ }
+ });
+ let accounts = await user.getAccounts();
+ user = user.get({ plain: true });
+ user.accounts = accounts;
+ user.payments = payments;
+
+ ws.user = user;
+ ws.send(JSON.stringify({ type: "login", data: user }));
+ ws.send(JSON.stringify({ type: "version", data: clientVersion }));
+ l.info("socket open", username, id);
+ } catch (e) {
+ ws.close();
+ l.error(
+ "problem opening socket",
+ token,
+ e.message,
+ id,
+ req.connection.remoteAddress
+ );
+ }
+ break;
+ case "token":
+ token = data;
+ if (!ws.user) return;
+ ws.token = token;
+ break;
+ default:
+ l.warn("received socket message of unknown type", type, data);
}
});
span> new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5229, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v); const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; // Is the composer fully inside the window, without scrolling? const composerFits = () => page.evaluate(() => { const c = document.querySelector('.chat-compose'); if (!c) return { found: false }; const r = c.getBoundingClientRect(); return { found: true, top: Math.round(r.top), bottom: Math.round(r.bottom), vh: window.innerHeight, fits: r.bottom <= window.innerHeight && r.top >= 0 }; }); try { for (const vp of [{ width: 1280, height: 800, name: 'laptop' }, { width: 390, height: 844, name: 'phone' }, { width: 1024, height: 600, name: 'short laptop' }]) { console.log(`\n[${vp.name} ${vp.width}x${vp.height}]`); await page.setViewport({ width: vp.width, height: vp.height }); if (vp.name === 'laptop') { await page.goto('http://localhost:5229/', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('button', 'Create a new wallet'); await sleep(300); await click('button', 'Import existing'); await sleep(300); await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('button', 'Open wallet'); await waitText('receive', 15000); // .header-msgs is the shared header-button class — settings, lock and // search wear it too, and the first one is no longer this one await page.evaluate(() => [...document.querySelectorAll('button')] .find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); await sleep(800); await click('button', 'Create'); await sleep(300); await setInput('input[placeholder*="name" i]', 'layout test'); await sleep(200); await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); }); await waitText('layout test', 25000); await click('.item', 'layout test'); await sleep(2000); } await sleep(600); let r = await composerFits(); check('composer is on screen', r.found && r.fits, `bottom ${r.bottom} of ${r.vh}`); // the state from the report: the invite panel open above the log await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); }); await sleep(600); const panelOpen = await page.$('.chat-invite'); r = await composerFits(); check('...and still on screen with the invite panel open', !!panelOpen && r.fits, `bottom ${r.bottom} of ${r.vh}`); // and with the member list open too await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-head .col')].find((n) => n.className.includes('clickable')); if (e) e.click(); }); await sleep(600); r = await composerFits(); check('...and with the member list open as well', r.fits, `bottom ${r.bottom} of ${r.vh}`); // NB not asserting the page never scrolls: the footer legitimately sits // below the fold. What matters is that the composer doesn't. // put it back for the next viewport await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-head .col')].find((n) => n.className.includes('clickable')); if (e) e.click(); }); await sleep(300); await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); }); await sleep(300); } await page.setViewport({ width: 1280, height: 800 }); await sleep(500); await page.screenshot({ path: shotPath }); console.log('\n[the channel header]'); const one = await page.evaluate(() => ({ picker: !!document.querySelector('.chan-pick'), named: document.body.innerText.includes('#general'), placeholder: (document.querySelector('#msg-draft') || {}).placeholder, })); check('a single channel is not named', !one.picker && !one.named, JSON.stringify(one)); check('and the composer says just "Message"', /^Message/.test(one.placeholder || '') && !/#/.test(one.placeholder || ''), one.placeholder); console.log('\n[adding a channel]'); await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); }); await sleep(600); const gated = await page.evaluate(() => ({ field: !!document.querySelector('input[placeholder*="channel" i]'), button: [...document.querySelectorAll('.chat-invite button')].some((b) => /new channel/i.test(b.textContent)), })); check('the naming field is not just sitting there', !gated.field, JSON.stringify(gated)); check('there is a New channel button instead', gated.button); await click('.chat-invite button', 'New channel'); await sleep(500); check('which reveals the field', !!(await page.$('input[placeholder*="channel" i]'))); await setInput('input[placeholder*="channel" i]', 'random'); await sleep(200); await page.evaluate(() => { const i = document.querySelector('input[placeholder*="channel" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); }); for (let i = 0; i < 60 && !(await page.$('.chan-pick')); i++) await sleep(500); // the picker appears on the relay echo, which can beat createChannel's own // continuation — give the field a moment to put itself away for (let i = 0; i < 20 && (await page.$('input[placeholder*="channel" i]')); i++) await sleep(500); const two = await page.evaluate(() => { const sel = document.querySelector('.chan-pick'); return { picker: !!sel, tag: sel && sel.tagName, opts: sel ? [...sel.options].map((o) => o.textContent) : [], field: !!document.querySelector('input[placeholder*="channel" i]') }; }); check('two channels become a select', two.picker && two.tag === 'SELECT' && two.opts.length === 2, JSON.stringify(two.opts)); check('and the naming field puts itself away', !two.field); const r2 = await composerFits(); check('composer still on screen', r2.fits, `bottom ${r2.bottom} of ${r2.vh}`); } catch (e) { check('run completed', false, e.message); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ the composer stays put' : '\n❌ failures above'); process.exit(ok ? 0 : 1);