aboutsummaryrefslogtreecommitdiffstats
path: root/src/dm-inbox.js
blob: 58fc72d3b94ec3cce3527266be81b3187f87ab71 (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
// What the service worker needs to tell a friend's DM from a stranger's.
//
// A NIP-17 wrap is signed by a throwaway key and the sender's identity sits
// under two layers of nip44, so no server can filter DMs by who sent them —
// only the recipient's own key can open one. The notifier therefore ships the
// wrap inside the push payload and the decision happens here, on the device.
//
// THREAT MODEL, stated plainly. This record holds the nostr identity secret,
// so anything with same-origin script access or a stolen unlocked device can
// read every DM this account can read. That is not a new exposure for a
// seed-derived identity — localStorage already holds the seed that key comes
// from — but it is a second copy, and it exists only to keep strangers from
// buzzing the phone. A remote signer (bunker/extension) never hands over its
// key: those wallets store no secret here and fall back to a generic
// notification instead of a filtered one.

import { unwrapDM } from './dm.js';
import { openCall, CALL, RING_S } from './calls.js';
import * as nip44 from 'nostr-tools/nip44';

const DB = 'coinos-dm-inbox';
const STORE = 'inbox';
const VERSION = 1;

function openDb() {
  return new Promise((resolve, reject) => {
    if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable'));
    const req = indexedDB.open(DB, VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function tx(mode, fn) {
  const db = await openDb();
  try {
    return await new Promise((resolve, reject) => {
      const t = db.transaction(STORE, mode);
      const s = t.objectStore(STORE);
      let out;
      Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject);
      t.oncomplete = () => resolve(out);
      t.onerror = () => reject(t.error);
      t.onabort = () => reject(t.error);
    });
  } finally { db.close(); }
}

// One record per wallet: { sk, pubkey, follows: [hex], names: { hex: name } }.
// `sk` is null when the identity lives behind a remote signer.
export async function saveInbox(walletKey, rec) {
  return tx('readwrite', (s) => { s.put(rec, walletKey); });
}
export async function clearInbox(walletKey) {
  return tx('readwrite', (s) => { s.delete(walletKey); });
}
export async function allInboxes() {
  try {
    return await tx('readonly', (s) => new Promise((res, rej) => {
      const out = [];
      const cur = s.openCursor();
      cur.onsuccess = () => {
        const c = cur.result;
        if (!c) return res(out);
        out.push(c.value);
        c.continue();
      };
      cur.onerror = () => rej(cur.error);
    }));
  } catch { return []; }
}

const unhex = (h) => Uint8Array.from(h.match(/.{2}/g).map((b) => parseInt(b, 16)));

// Open a wrap against every identity we hold a key for. Returns
// { author, followed, name, mine } for the first one that opens it, or null
// if none can — which includes every remote-signer wallet, so a null means
// "we don't know", never "it's a stranger".
export async function classifyDm(wrap, inboxes) {
  for (const rec of inboxes) {
    if (!rec || !rec.sk) continue;
    let sk;
    try { sk = unhex(rec.sk); } catch { continue; }
    const opened = await unwrapDM(wrap, sk);
    if (!opened) continue;
    const author = opened.author;
    return {
      author,
      mine: author === rec.pubkey,
      followed: (rec.follows || []).includes(author),
      muted: (rec.muted || []).includes(author),
      // Someone you're already talking to is not a stranger, whether or not
      // you ever followed them.
      known: (rec.known || []).includes(author),
      // No contact list published at all: there's nothing to filter against,
      // and silence would be worse than noise.
      hasList: !!rec.hasList,
      name: (rec.names || {})[author] || null,
      content: opened.rumor && opened.rumor.kind === 14 ? opened.rumor.content : '',
    };
  }
  return null;
}

// Should this DM interrupt someone? Our own sent-copy never should — every
// NIP-17 message is wrapped to the sender too, and being notified about what
// you just typed is pure noise. Anything we couldn't open stays notifiable:
// better a generic buzz than a silent drop.
export function shouldNotifyDm(verdict) {
  if (!verdict) return true;
  if (verdict.mine) return false;
  if (verdict.muted) return false; // muted in the app: no buzz either
  if (verdict.followed || verdict.known) return true;
  return !verdict.hasList;
}

// A call ringing in (NIP-AC), the wrap a push pointed at: opened with the key
// it's for, and worth ringing only as a fresh offer from someone this
// account follows or talks to, and hasn't muted. Returns { offer, name,
// type } to ring with, or null for anything else (a candidate, a stranger,
// a stale one, a wrap we hold no key for); `quiet` for a caller who
// may not ring us (not followed, not written to).
export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / 1000)) {
  for (const rec of inboxes) {
    if (!rec || !rec.sk) continue;
    let sk;
    try { sk = unhex(rec.sk); } catch { continue; }
    const ev = await openCall(wrap, async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, pk)));
    if (!ev) continue;
    const from = ev.pubkey;
    if (ev.kind !== CALL.OFFER || from === rec.pubkey || nowS - ev.created_at > RING_S) return null;
    if ((rec.muted || []).includes(from)) return null;
    const type = (ev.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
    // someone not followed nor written to doesn't ring — `quiet`: the app
    // lists it as a call that didn't ring, next time it opens
    const quiet = !(rec.follows || []).includes(from) && !(rec.rings || rec.known || []).includes(from);
    return { offer: ev, name: (rec.names || {})[from] || null, type, quiet };
  }
  return null;
}