aboutsummaryrefslogtreecommitdiffstats
path: root/src/sw-nwc.js
blob: f9d383ef33f8f958b70fae3fe5dd9a20ee8ebee2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// Service-worker side of background NWC. Bundled by build.js and appended to
// the generated sw.js, replacing the notify-only push handler: a push now
// first tries to ANSWER the request from the background state mirror (see
// nwc-respond.js), and only falls back to the wake-the-user notification
// when it can't.

import { respondFromBg, tendRenewals } from './nwc-respond.js';
import { withRequestLock } from './nwc-lock.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';

const NOTIFIER = 'https://nwcpush.coinos.io';
const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens
// where a call's offer wrap is fetched from: the notifier, or (the app served
// on this machine, as the tests do) this origin
const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER;

self.addEventListener('push', (e) => {
  let data = {};
  try { data = e.data ? e.data.json() : {}; } catch (_) {}
  // User-facing notifications (payments, DMs, community chat). An open
  // window already shows everything live — stay quiet then.
  // A call ringing in: the push names the offer's wrap, kept at the notifier
  // (too big to ride in a push). Opened here with our key; a fresh offer
  // from someone we follow or talk to rings, as a notification that stays
  // until it's answered or missed. The offer is kept for the app to pick up
  // when the tap opens it (CALL_CACHE). An open window rings by itself.
  if (data.type === 'notify' && data.reason === 'call') {
    e.waitUntil((async () => {
      const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
      if (clients.some((c) => c.visibilityState === 'visible')) return;
      let ring = null;
      try {
        const r = await fetch(WRAPS + '/wrap/' + encodeURIComponent(data.wrapId || ''));
        if (r.ok) ring = await classifyCall(await r.json(), await allInboxes());
      } catch (err) { console.warn('[sw-call] could not open:', err && err.message); }
      if (!ring) return;
      if (ring.quiet) {
        // no ring for them; a note for the app, which lists it as a missed call
        try {
          const cache = await caches.open(CALL_CACHE);
          const had = await cache.match('/__quiet-calls');
          const list = had ? await had.json() : [];
          list.push(ring.offer);
          await cache.put('/__quiet-calls', new Response(JSON.stringify(list.slice(-20)), { headers: { 'content-type': 'application/json' } }));
        } catch (_) {}
        return;
      }
      try {
        const cache = await caches.open(CALL_CACHE);
        await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } }));
      } catch (_) {}
      for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} }
      await self.registration.showNotification((ring.name || 'Someone') + ' is calling', {
        body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.',
        icon: 'icon-192.png', badge: 'badge-96.png',
        tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400],
        data: { url: './?open=call', view: 'call' },
      });
    })());
    return;
  }
  if (data.type === 'notify') {
    e.waitUntil((async () => {
      const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
      if (clients.some((c) => c.visibilityState === 'visible')) return;
      // Money landing is exactly when a forwarding rule wants to run, and this
      // wake-up is the only moment the worker gets. Failures are recorded in
      // the mirror; the notification still goes out either way.
      if (data.reason === 'payment' || data.reason === 'zap') {
        try { await bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }); }
        catch (err) { console.warn('[sw-aw] failed:', err && err.message); }
      }
      // A DM arrives wrapped, so only this device can see who sent it. Open it
      // here: a message from someone we follow gets their name on it, our own
      // sent-copy and a stranger's get nothing at all. Anything we can't open
      // — a remote-signer wallet, an oversized wrap — falls through to the
      // generic notification rather than being dropped silently.
      let dmName = null;
      if (data.reason === 'dm' && data.wrap) {
        try {
          const verdict = await classifyDm(data.wrap, await allInboxes());
          if (!shouldNotifyDm(verdict)) return;
          if (verdict && verdict.name) dmName = verdict.name;
        } catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); }
      }
      // A reaction names who, when this device knows them, and is dropped
      // when they are muted here — the notifier can't know either.
      let who = null;
      if (data.reason === 'reaction' && data.from) {
        const boxes = await allInboxes().catch(() => []);
        if (boxes.some((b) => (b.muted || []).includes(data.from))) return;
        who = boxes.map((b) => b.names && b.names[data.from]).find(Boolean) || null;
      }
      const emoji = !data.emoji || data.emoji === '+' ? '\u2764\ufe0f' : /^:[\w-]+:$/.test(data.emoji) ? data.emoji : data.emoji.slice(0, 8);
      const T = {
        payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Open coinos to see it.'],
        dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'],
        chat: ['New chat activity', 'There are new messages in your communities.'],
        mention: [data.reply ? 'New reply to your post' : 'You were mentioned', data.text || 'Open coinos to see it.'],
        zap: ['Zap received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Someone zapped you. Open coinos to see it.'],
        reaction: [who ? `${who} reacted ${emoji}` : `New reaction ${emoji}`, 'to your post. Open coinos to see it.'],
      };
      const [title, body] = T[data.reason] || T.chat;
      // a reply lands you in the Notifications list, where it is
      const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' };
      await self.registration.showNotification(title, {
        body,
        icon: 'icon-192.png', badge: 'badge-96.png',
        tag: 'notify-' + (data.reason || 'chat'), renotify: ['payment', 'zap', 'mention'].includes(data.reason),
        data: target,
      });
    })());
    return;
  }
  if (data.type !== 'nwc') return;
  const startedAt = Date.now();
  e.waitUntil((async () => {
    // An open window handles requests itself with full wallet state — nudge
    // it and stay out of the way.
    const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
    if (clients.length) {
      for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
      return;
    }
    // A wake is the only moment a closed wallet gets to look after its coins:
    // claim renewals whose round has run, cover coins without one. After the
    // answer, inside what is left of the push budget.
    const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) })
      .catch((err) => console.warn('[sw-renew] failed:', err && err.message));
    // an NWC wake-up may also have moved money in; check the rule after
    setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0);
    let handled = false;
    try {
      handled = await withRequestLock(data.event && data.event.id,
        () => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }),
        () => { console.log('[sw-nwc] another context holds this request'); return true; });
    } catch (err) {
      console.warn('[sw-nwc] auto-answer failed:', err && err.message);
    }
    // handled-with-a-heads-up: e.g. an invoice was minted while closed and
    // the user should open coinos to complete the receive
    if (handled && handled.notify) {
      await self.registration.showNotification(handled.notify.title, {
        body: handled.notify.body,
        icon: 'icon-192.png', badge: 'badge-96.png',
        tag: handled.notify.tag || 'nwc-incoming', renotify: true,
        data: { url: './' },
      });
      await tend();
      return;
    }
    if (handled) { await tend(); return; }
    // A kind-21001 offer request is money coming IN (someone asking us to
    // mint an invoice) — "an app wants to pay" would read as the opposite.
    const incoming = data.event && data.event.kind === 21001;
    await self.registration.showNotification(incoming ? 'Incoming payment' : 'Payment request', {
      body: incoming
        ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.'
        : 'An app is asking your wallet to pay. Open Coinos to approve.',
      icon: 'icon-192.png', badge: 'badge-96.png',
      tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
      data: { url: './' },
    });
  })());
});

self.addEventListener('notificationclick', (e) => {
  e.notification.close();
  const d = (e.notification && e.notification.data) || {};
  e.waitUntil((async () => {
    const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
    const open = all.find((c) => 'focus' in c);
    if (open) {
      // an open window goes where the notification points, without reloading
      if (d.view) { try { open.postMessage({ type: 'open', view: d.view }); } catch (_) {} }
      return open.focus();
    }
    if (self.clients.openWindow) return self.clients.openWindow(d.url || './');
  })());
});