summaryrefslogtreecommitdiffstats
path: root/src/dm-inbox.js
blob: a91a8e093c8f675d256ecede5bfbd20b4db61ed0 (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
// 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';

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),
      // 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.followed || verdict.known) return true;
  return !verdict.hasList;
}